diff --git a/.gitignore b/.gitignore
index 34bccf99..ea5bd5ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,38 @@
+# macOS
+.DS_Store
+
# Docs
-public
+__pycache__
+docs/build
+/public
+js/public
+doc
# JS
node_modules
.eslintcache
tsconfig.tsbuildinfo
+dist
+
+# JS CLI
+js/cli/release
+auth.txt
+auth-session.json
+cache*.sqlite
+events.json
+events.lock
+clientUid.json
+*.log
+*.bun-build
+config.json
+*.map
+
+# IDEs
+.vs
+.vscode
+.idea
+*.swp
+
+# Tests
+tests/storage
+tests/test-results
diff --git a/LICENSE.md b/LICENSE.md
index 7b7ce730..8f8e23f2 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,3 +1,21 @@
-# License
+The MIT License
-TBD
+Copyright (c) 2025-2026 Proton AG
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/README.md b/README.md
index 1726f757..3fd5f131 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,102 @@
-# Drive SDK
+# Proton Drive SDK
-Copyright (c) 2025 Proton AG
+The Proton Drive SDK provides a high-level interface for interacting with Proton Drive. It is available in the following languages:
-TBD
+- **TypeScript** — native SDK in [`js/sdk/`](./js/sdk/), available on npm as [`@protontech/drive-sdk`](https://www.npmjs.com/package/@protontech/drive-sdk). See [changelog](./js/CHANGELOG.md) for changes.
+- **C#** — native SDK in [`cs/sdk/`](./cs/sdk/). See [changelog](./cs/CHANGELOG.md) for changes.
+- **Kotlin** — bindings that wrap the C# SDK in [`kt/`](./kt/). See [changelog](./cs/CHANGELOG.md) for changes to the C# SDK.
+- **Swift** - bindings that wrap the C# SDK in [`swift/ProtonDriveSDK/`](./swift/ProtonDriveSDK/), available on github as [`sdk-swift`](https://github.com/ProtonDriveApps/sdk-swift). See [changelog](./cs/CHANGELOG.md) for changes to the C# SDK.
-## Contributions
+### Who this is for
-Contributions are not accepted at the moment.
+| Audience | Expectations |
+| --- | --- |
+| **Proton first-party clients** | Primary focus today: this codebase is built for and validated alongside official Proton Drive apps. |
+| **Personal, non-commercial projects** | Allowed under [Guidelines](#usage-guidelines-for-personal-projects) below. Expect interface changes and the upcoming cryptographic migration until general availability. |
+| **Commercial or production third-party apps** | The SDK is not yet ready for third-party production use. |
+
+Using the SDK directly is still recommended over raw Drive API calls for any experimentation, so correctness, safety and rate-limit expectations stay aligned with first-party behavior. The SDK handles encryption and metadata processing, protecting uploaded data from corruption due to incorrect encryption or invalid metadata.
+
+## Current Status
+
+The SDK is actively being integrated into official Proton Drive clients. During this phase, the architecture and public interface may still change.
+
+**Upcoming cryptographic model change**:
+
+- **What changes:** Proton Drive will move to a new cryptographic model that improves performance, simplifies the architecture, and strengthens security.
+- **When:** Currently targeted for the **end of 2026/early 2027**. This window is an estimate and may shift; final timing and migration steps will be documented in this README and in the changelogs when they are finalized.
+- **What breaks:** Once the service uses the new model, any client that only implements the previous cryptography including older SDK releases will **not** interoperate until upgraded to a release that implements the new model.
+- **How to stay informed:** Watch this repository and read changelogs and README for migration notes and definitive dates.
+
+Once these changes are complete and the integration is stable, the SDK will be officially released for third-party use.
+
+Despite not being officially supported for third-party use at present, Proton strongly recommends integrating through this SDK rather than calling the Drive API directly. It is the same implementation used in Proton's first-party clients and is maintained to the same quality standards, even while the public interface continues to evolve. If you integrate without the SDK, you must still follow those guidelines; non-compliant clients may be rate-limited or blocked to protect Proton Drive and other users.
+
+## Usage Guidelines for Personal Projects
+
+The SDK may be used for personal, non-commercial projects. If you choose to build an application using Proton Drive, you **must** adhere to the requirements below.
+
+### Operational requirements
+
+These rules protect service availability and honest identification of clients. Rate limits are per session and user, thus third-party applications use the **same rate-limiting policy** as Proton first-party Drive clients.
+
+| Requirement | Description |
+| --- | --- |
+| **Use the SDK** | You are strongly encouraged to interact with Proton Drive through the SDK. If you make direct API calls, your application **must** implement the same correctness and safety guarantees as the SDK. Failing to use appropriate caching, event-based sync, parallelism limits, and exponential backoff may cause your application to be rate-limited to protect service availability. |
+| **Use official endpoints** | All HTTP requests must go to the official Proton Drive domain. Do not modify or proxy API endpoints to different domains. |
+| **Identify your application** | Set the `x-pm-appversion` HTTP header so it identifies your build honestly. Use the shape described below (for example, `external-drive-myapp@1.2.3-stable`). The value must accurately represent your application. Do not spoof or falsify this header. Third-party clients that seek to masquerade as official Proton first-party clients are forbidden and may stop working at any time. Customer support and development use the reported app version to troubleshoot requests; a **specific version may be blocked** if it is known to ship a serious bug. |
+| **Use event-based sync** | Synchronize data using Drive events. Do not poll the API or perform frequent recursive traversals of the file tree. Excessive polling or recursion may cause your application and your account to be rate-limited to protect service availability. |
+
+Use this pattern for `x-pm-appversion`:
+
+`external-drive-{name}@{semver}-{channel}+{suffix}` with optional SemVer build metadata `+{suffix}` (for example a short commit hash).
+
+- **`{name}`** — your project identifier using lowercase letters and underscores (e.g. `my_app`).
+- **`{semver}`** — `major.minor.patch` (e.g. `1.2.3`).
+- (optional) **`{channel}`** — one of `stable`, `beta`, or `alpha`.
+- (optional) **`+{suffix}`** — build metadata, for example a short commit hash (e.g. `+abc123f`).
+
+Examples:
+
+- `external-drive-myapp@1.2.3-stable`
+- `external-drive-my_app@2.0.0-beta`
+- `external-drive-photo_backup@1.0.0-alpha+abc123f`
+
+### Product and legal requirements
+
+These rules keep third-party apps distinguishable from official Proton products and transparent to users.
+
+| Requirement | Description |
+| --- | --- |
+| **No Proton branding** | Your application must not use Proton logos, trademarks, or design elements. It must be clearly distinguishable as an unofficial, third-party product. |
+| **Credential handling disclosure** | When you prompt a user for account details (including but not limited to username and password) your application must clearly state that it is a third-party application not officially supported by Proton. Suggested text: _This is a third-party application not officially supported by Proton._ |
+
+To protect the availability of Proton Drive and to properly safeguard the Proton customer experience, failure to comply with these requirements may result in your third-party application being limited or blocked from accessing Proton services. If you believe your third-party application has been improperly limited and/or blocked, please contact customer support on [proton.me/support/contact](https://proton.me/support/contact).
+
+## Scope and Limitations
+
+The SDK provides functionality for Proton Drive business logic only. It does **not** include:
+
+- Authentication or login flows
+- Session management
+- User address provider
+
+**Where to look first:** Official Proton Drive clients wire these pieces into the SDK; treat them as the living reference until this repository publishes standalone sample apps. Standalone integration support will be documented once the SDK reaches general availability.
+
+## Documentation
+
+We are preparing the documentation for the SDK. It will be available in the future.
+
+Until then, you can generate the code reference for the TypeScript SDK using the following command:
+
+```bash
+cd js/sdk && OUTPUT_PATH=./doc npm run generate-docs
+```
+
+## License
+
+This project is licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
+
+> **Using Proton’s hosted services:** The MIT license governs **use of the source code in this repository** only. Access to **Proton’s hosted services** (including Proton Drive) remains subject to separate terms of service and operational policies. Integration rules and enforcement described in this README apply regardless of the OSS license.
+
+Copyright (c) 2026 Proton AG
diff --git a/SECURITY.md b/SECURITY.md
index 7b5ad73d..46e68856 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,3 +1,7 @@
# Security
-TBD
+If you discover a security vulnerability in the Proton Drive SDK, please report it through our official bug bounty program:
+
+**[Proton Bug Bounty Program](https://proton.me/security/bug-bounty)**
+
+For general security inquiries, you can reach us at [security@proton.me](mailto:security@proton.me).
diff --git a/cs/.editorconfig b/cs/.editorconfig
new file mode 100644
index 00000000..70312fa4
--- /dev/null
+++ b/cs/.editorconfig
@@ -0,0 +1,210 @@
+# Remove the line below if you want to inherit .editorconfig settings from higher directories
+root = true
+
+[**/obj/**.cs]
+generated_code = true
+
+# C# files
+[*.cs]
+
+#### Core EditorConfig Options ####
+
+# Indentation and spacing
+indent_size = 4
+indent_style = space
+tab_width = 4
+
+# New line preferences
+insert_final_newline = true
+
+#### .NET Coding Conventions ####
+
+# Make build follow IDE severities
+dotnet_analyzer_diagnostic.severity = default
+
+# Organize usings
+dotnet_separate_import_directive_groups = false
+dotnet_sort_system_directives_first = true
+
+# this. and Me. preferences
+dotnet_style_qualification_for_event = false:silent
+dotnet_style_qualification_for_field = false:silent
+dotnet_style_qualification_for_method = false:silent
+dotnet_style_qualification_for_property = false:silent
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:silent
+dotnet_style_predefined_type_for_member_access = true:silent
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true:suggestion
+dotnet_style_collection_initializer = true:suggestion
+dotnet_style_explicit_tuple_names = true:suggestion
+dotnet_style_null_propagation = true:suggestion
+dotnet_style_object_initializer = true:suggestion
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+dotnet_style_prefer_auto_properties = true:silent
+dotnet_style_prefer_compound_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_assignment = true:silent
+dotnet_style_prefer_conditional_expression_over_return = true:silent
+dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
+dotnet_style_prefer_inferred_tuple_names = true:suggestion
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
+dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
+dotnet_style_prefer_simplified_interpolation = true:suggestion
+
+# Field preferences
+dotnet_style_readonly_field = true:suggestion
+
+# Parameter preferences
+dotnet_code_quality_unused_parameters = all:suggestion
+
+#### C# Coding Conventions ####
+
+# var preferences
+csharp_style_var_elsewhere = true
+csharp_style_var_for_built_in_types = true
+csharp_style_var_when_type_is_apparent = true
+
+# Expression-bodied members
+csharp_style_expression_bodied_accessors = true:silent
+csharp_style_expression_bodied_constructors = false:silent
+csharp_style_expression_bodied_indexers = true:silent
+csharp_style_expression_bodied_lambdas = true:silent
+csharp_style_expression_bodied_local_functions = false:silent
+csharp_style_expression_bodied_methods = false:silent
+csharp_style_expression_bodied_operators = false:silent
+csharp_style_expression_bodied_properties = true:silent
+
+# Pattern matching preferences
+csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
+csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
+csharp_style_prefer_switch_expression = true:suggestion
+
+# Null-checking preferences
+csharp_style_conditional_delegate_call = true:suggestion
+
+# Modifier preferences
+csharp_prefer_static_local_function = true:suggestion
+csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent
+
+# Code-block preferences
+csharp_prefer_braces = true:silent
+csharp_prefer_simple_using_statement = true:suggestion
+
+# Expression-level preferences
+csharp_prefer_simple_default_expression = true:suggestion
+csharp_style_deconstructed_variable_declaration = true:suggestion
+csharp_style_inlined_variable_declaration = true:suggestion
+csharp_style_pattern_local_over_anonymous_function = true:suggestion
+csharp_style_prefer_index_operator = true:suggestion
+csharp_style_prefer_range_operator = true:suggestion
+csharp_style_throw_expression = true:suggestion
+csharp_style_unused_value_assignment_preference = discard_variable:suggestion
+csharp_style_unused_value_expression_statement_preference = discard_variable:silent
+
+# Namespace declarations
+csharp_style_namespace_declarations = file_scoped:warning
+
+# 'using' directive preferences
+csharp_using_directive_placement = outside_namespace:silent
+
+#### C# Formatting Rules ####
+
+# New line preferences
+csharp_new_line_before_catch = true
+csharp_new_line_before_else = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_anonymous_types = false
+csharp_new_line_before_members_in_object_initializers = false
+csharp_new_line_before_open_brace = all
+csharp_new_line_between_query_expression_clauses = true
+
+roslynator_max_line_length = 160
+
+# Indentation preferences
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_labels = one_less_than_current
+csharp_indent_switch_labels = true
+
+# Space preferences
+csharp_space_after_cast = false
+csharp_space_after_colon_in_inheritance_clause = true
+csharp_space_after_comma = true
+csharp_space_after_dot = false
+csharp_space_after_keywords_in_control_flow_statements = true
+csharp_space_after_semicolon_in_for_statement = true
+csharp_space_around_binary_operators = before_and_after
+csharp_space_around_declaration_statements = false
+csharp_space_before_colon_in_inheritance_clause = true
+csharp_space_before_comma = false
+csharp_space_before_dot = false
+csharp_space_before_open_square_brackets = false
+csharp_space_before_semicolon_in_for_statement = false
+csharp_space_between_empty_square_brackets = false
+csharp_space_between_method_call_empty_parameter_list_parentheses = false
+csharp_space_between_method_call_name_and_opening_parenthesis = false
+csharp_space_between_method_call_parameter_list_parentheses = false
+csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
+csharp_space_between_method_declaration_name_and_open_parenthesis = false
+csharp_space_between_method_declaration_parameter_list_parentheses = false
+csharp_space_between_parentheses = false
+csharp_space_between_square_brackets = false
+
+# Wrapping preferences
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
+
+#### Naming styles ####
+
+# Naming rules
+
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+# Symbol specifications
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
diff --git a/cs/.gitignore b/cs/.gitignore
new file mode 100644
index 00000000..875100c3
--- /dev/null
+++ b/cs/.gitignore
@@ -0,0 +1,362 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ar]tifacts/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUNIT
+*.VisualState.xml
+TestResult.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# JustCode is a .NET coding add-in
+.JustCode
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+# *.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- Backup*.rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# JetBrains Rider
+.idea/
+*.sln.iml
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Artifacts folder
+/artifacts
+
+# vcpkg
+vcpkg_installed/
+
+# Visual Studio project launch settings
+**/Properties/launchSettings.json
+
+build/output
+.vscode
+
+# DocFX
+docfx/api/
+docfx/_site/
+.tools/
+
+# macOS
+*.DS_Store
+.AppleDouble
+.LSOverride
diff --git a/cs/.globalconfig b/cs/.globalconfig
new file mode 100644
index 00000000..cb95dd6e
--- /dev/null
+++ b/cs/.globalconfig
@@ -0,0 +1,143 @@
+is_global = true
+
+stylecop.layout.allowConsecutiveUsings = true
+stylecop.layout.allowDoWhileOnClosingBrace = true
+
+# IDE0001: Simplify Names
+dotnet_diagnostic.IDE0001.severity = warning
+
+# IDE0002: Simplify Member Access
+dotnet_diagnostic.IDE0002.severity = warning
+
+# IDE0003: Remove qualification
+dotnet_diagnostic.IDE0003.severity = warning
+
+# IDE0004: Remove Unnecessary Cast
+dotnet_diagnostic.IDE0004.severity = warning
+
+# IDE0005: Using directive is unnecessary.
+dotnet_diagnostic.IDE0005.severity = warning
+
+# IDE0007: Use var instead of explicit type
+dotnet_diagnostic.IDE0007.severity = warning
+
+# IDE0028: Use collection initializers or expressions
+dotnet_diagnostic.IDE0028.severity = warning
+
+# IDE0047: Remove unnecessary parentheses
+dotnet_diagnostic.IDE0047.severity = warning
+
+# CA1032: Implement standard exception constructors
+dotnet_diagnostic.CA1032.severity = warning
+
+# CS1591: Missing XML comment for publicly visible type or member
+dotnet_diagnostic.CS1591.severity = none
+
+# CA1711: Identifiers should not have incorrect suffix
+dotnet_diagnostic.CA1711.severity = warning
+
+# CA1849: Call async methods when in an async method
+dotnet_diagnostic.CA1849.severity = warning
+
+# CA2000: Dispose objects before losing scope
+dotnet_diagnostic.CA2000.severity = suggestion
+
+# CA2201: Do not raise reserved exception types
+dotnet_diagnostic.CA2201.severity = warning
+
+# CA2215: Dispose methods should call base class dispose
+dotnet_diagnostic.CA2215.severity = warning
+
+# StyleCop - Special
+# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SpecialRules.md
+
+dotnet_diagnostic.SA0001.severity = none
+
+# StyleCop - Spacing
+# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/SpacingRules.md
+
+dotnet_diagnostic.SA1009.severity = suggestion
+
+# StyleCop - Readability
+# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/ReadabilityRules.md
+
+dotnet_diagnostic.SA1101.severity = none
+dotnet_diagnostic.SA1516.severity = none
+
+# StyleCop - Naming
+# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/NamingRules.md
+
+dotnet_diagnostic.SA1309.severity = none
+
+# StyleCop - Documentation
+# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/DocumentationRules.md
+
+dotnet_diagnostic.SA1600.severity = none
+dotnet_diagnostic.SA1601.severity = none
+dotnet_diagnostic.SA1602.severity = none
+dotnet_diagnostic.SA1604.severity = none
+dotnet_diagnostic.SA1605.severity = none
+dotnet_diagnostic.SA1606.severity = none
+dotnet_diagnostic.SA1607.severity = none
+dotnet_diagnostic.SA1608.severity = none
+dotnet_diagnostic.SA1610.severity = none
+dotnet_diagnostic.SA1611.severity = none
+dotnet_diagnostic.SA1612.severity = none
+dotnet_diagnostic.SA1613.severity = none
+dotnet_diagnostic.SA1614.severity = none
+dotnet_diagnostic.SA1615.severity = none
+dotnet_diagnostic.SA1616.severity = none
+dotnet_diagnostic.SA1617.severity = none
+dotnet_diagnostic.SA1618.severity = none
+dotnet_diagnostic.SA1619.severity = none
+dotnet_diagnostic.SA1620.severity = none
+dotnet_diagnostic.SA1621.severity = none
+dotnet_diagnostic.SA1622.severity = none
+dotnet_diagnostic.SA1623.severity = none
+dotnet_diagnostic.SA1624.severity = none
+dotnet_diagnostic.SA1625.severity = none
+dotnet_diagnostic.SA1626.severity = none
+dotnet_diagnostic.SA1627.severity = none
+dotnet_diagnostic.SA1629.severity = none
+dotnet_diagnostic.SA1633.severity = none
+dotnet_diagnostic.SA1634.severity = none
+dotnet_diagnostic.SA1635.severity = none
+dotnet_diagnostic.SA1636.severity = none
+dotnet_diagnostic.SA1637.severity = none
+dotnet_diagnostic.SA1638.severity = none
+dotnet_diagnostic.SA1640.severity = none
+dotnet_diagnostic.SA1641.severity = none
+dotnet_diagnostic.SA1642.severity = none
+dotnet_diagnostic.SA1643.severity = none
+dotnet_diagnostic.SA1648.severity = none
+
+# StyleCop - Alternative
+# https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/AlternativeRules.md
+
+dotnet_diagnostic.SX1101.severity = warning
+dotnet_diagnostic.SX1309.severity = warning
+
+# Roslynator
+
+# RCS0056: A line is too long
+dotnet_diagnostic.RCS0056.severity = warning
+
+# RCS1037: Remove trailing white-space
+dotnet_diagnostic.RCS1037.severity = none # Redundant with SA1028
+
+# RCS1047: Non-asynchronous method name should not end with 'Async'
+dotnet_diagnostic.RCS1047.severity = warning
+
+# RCS1118: Mark local variable as const
+dotnet_diagnostic.RCS1118.severity = warning
+
+# RCS1090: Add parameter to exception constructor
+dotnet_diagnostic.RCS1194.severity = none # Redundant with CA1032
+
+# Sonar
+
+# S1134: Track uses of "FIXME" tags
+dotnet_diagnostic.S1134.severity = suggestion
+
+# S1135: Complete the task associated to this 'TODO' comment
+dotnet_diagnostic.S1135.severity = suggestion
diff --git a/cs/CHANGELOG.md b/cs/CHANGELOG.md
new file mode 100644
index 00000000..32fd4ea7
--- /dev/null
+++ b/cs/CHANGELOG.md
@@ -0,0 +1,572 @@
+# Changelog
+
+## cs/v0.15.1 (2026-05-28)
+
+* Fix node secrets not being read from cache
+* Use photos API when fetching album and photo node details
+* Merge result error message with first error message
+* Fix E2E kotlin tests
+
+## cs/v0.15.0 (2026-05-27)
+
+* Report extended attributes size for download progress instead of revision size
+* Fix cache not evicting incompatible entries
+* Do not close the input stream in Swift's StreamForUpload
+* Fix interop account client requesting empty address instead of default address
+* Use single type hierarchy for nodes
+* Retry block encryption and report metric
+* Wrap node not found into a dedicated exception
+
+## cs/v0.14.6 (2026-05-22)
+
+* Make last modification time optional for file uploads
+* Show what was actually in the JSON when extended attributes cannot be parsed
+
+## cs/v0.14.5 (2026-05-18)
+
+* Fix missing disposal of reader in Sqlite cache repository
+* Fix error mapping for decryption
+
+## cs/v0.14.4 (2026-05-14)
+
+* Fix incorrect reporting of decryption errors
+
+## cs/v0.14.3 (2026-05-11)
+
+* Handle degraded folder secrets in upload and node operations
+* Classify HTTP response code 499 as server error
+* Flatten messages of decryption errors reported to telemetry
+* Reproduce content size mismatch
+* Add an E2E tests for conflict name with draft
+* Add info log for uploader and downloader
+* Fix upload failing to resume when blocks were uploaded out of order
+* Fix handling of mismatch between uploaded and intended sizes
+* Remove slash validation name after decryption
+
+## cs/v0.14.2 (2026-05-06)
+
+* Dispose upload controller in test to see events
+* Make cryptography time monotonic
+* Optional AccountClientProtocol + interop nil handling
+
+## cs/v0.14.1 (2026-05-01)
+
+* Refactor Proton API exception to consolidate constructor initialization
+* Add error for verification error event
+* Include error details in decryption telemetry events
+
+## cs/v0.14.0 (2026-04-28)
+
+* Fix name conflict handling regression
+* Reduce log level for draft deletion failure from error to warning
+* Evict non-deserializable entries from cache
+* Upgrade to .NET 10
+* Fix download queuing not blocking on full queue
+
+## cs/v0.13.8 (2026-04-27)
+
+* Fix nullable data in name conflict error
+* Add extension to aborted exception
+* Reduce log in controllers
+* Improve exception type names in error reports
+
+## cs/v0.13.7 (2026-04-23)
+
+* Remove unnecessary too many children exception
+* Log error when volume type is unknown
+
+## cs/v0.13.6 (2026-04-22)
+
+* Handle too many children exception when creating a new draft
+
+## cs/v0.13.5 (2026-04-22)
+
+* Add thumbnail error handling from API response
+* Ensure expected SHA1 provider is called only once during upload
+
+## cs/v0.13.4 (2026-04-20)
+
+* Improve download initialization speed by parallelizing some server round-trips
+* Add get node for Kotlin drive client
+* Fix memory leak on SHA1 provision through interop
+
+## cs/v0.13.3 (2026-04-16)
+
+* Fix failure to upload new revision on single file sharing
+
+## cs/v0.13.2 (2026-04-07)
+
+* Resume continuation only when active
+* Log network error and retries in kotlin
+
+## cs/v0.13.1 (2026-04-03)
+
+* Update logs from kotlin resume api
+* Fix feature flag parsing in kotlin
+
+## cs/v0.13.0 (2026-04-02)
+
+* Keep http request body in kotlin memory for retries
+* Fix illegal assignments of null values to Protobuf fields for authorship results
+* Enable streaming of results when enumerating folder children and Photos timeline
+* Fix function to get node from Photos client not using Photos API
+* Log network body for tests by chunk
+* Extract clients interfaces
+* Add trash management to Photos
+
+## cs/v0.12.0 (2026-03-30)
+
+* Remove get thumbnails in favor of enumerate thumbnails
+* Introduce uids in the kotlin bindings
+* Move native weak reference management to kotlin
+* Do not call interop functions if cancelled
+* Fix thumbnail enumeration to stay within API limits
+* Fix cancellation in download and upload
+* Log network calls with body size
+* Add streaming thumbnails enumeration to Swift bindings
+* Remove the need to dispose of Photos client
+
+## cs/v0.11.2 (2026-03-27)
+
+* Stream trash enumeration instead of loading all items at once
+* Fix regression in disposal of file transfer controllers
+* Update Swift binding to get trash error
+
+## cs/v0.11.1 (2026-03-25)
+
+* Wrap SDK exception into IO exception for android network library to handle it
+
+## cs/v0.11.0 (2026-03-24)
+
+* Surface non-resumable upload and download as typed exceptions
+
+## cs/v0.10.0 (2026-03-24)
+
+* Enable resuming of uploads from Swift bindings
+
+## cs/v0.9.4 (2026-03-23)
+
+* Fix wrong volume type for photo events
+* Expose structured data on upload integrity errors to Swift binding
+
+## cs/v0.9.3 (2026-03-23)
+
+* Mark checksum verified as optional in the api
+* Report checksum verification state to interop
+
+## cs/v0.9.2 (2026-03-20)
+
+* Report checksum verification state to back-end and client
+
+## cs/v0.9.1 (2026-03-20)
+
+* Report unmapped HTTP errors as Network errors instead of Unknown
+* Allow resuming download to non seekable data stream
+* Fix wrong link details endpoint being used for Photos
+* Improve error details for node decryption failures
+
+## cs/v0.9.0 (2026-03-20)
+
+* Fail node provision when parent key could not be obtained
+* Try all album inclusions to find the entry point key
+* Handle missing timestamps in photo upload metadata
+* Improve error details for drive errors
+* Remove failing test data
+* Fix telemetry causing deadlock on uploads and downloads
+* Expose structured data on upload integrity errors
+* Throw error if node is not found
+* Parse enumerate result synchronously
+* Clarify exception for missing node when looking up entry point
+* Fix setup for timeouts in test
+* Log number of ids when enumerate thumbnails
+
+## cs/v0.8.1 (2026-03-16)
+
+* Fix disposal of upload controller and update upload bindings api
+* Add streaming thumbnails enumeration for Drive and Photos clients
+* Update download event values for tests
+
+## cs/v0.8.0 (2026-03-12)
+
+* Implement upload to Photos
+* Set swift error message
+* Handle nullable OwnedBy fields when mapping to proto
+* Propagate individual thumbnail errors to callers instead of silently skipping them
+* Add owned by property
+
+## cs/v0.7.0-alpha.17 (2026-03-10)
+
+* Fix manifest verification errors due to wrong thumbnail order in manifest
+* Prevent resumed uploads from being paused by a stale previous attempt
+* Use java Instant instead for Long to describe time
+* Add interop and Kotlin bindings for trash management
+* Add context traversal for photo nodes and set telemetry volume type
+* Log failed attempts to report decryption errors to telemetry
+* Align telemetry with the web SDK
+
+## cs/v0.7.0-alpha.16 (2026-03-04)
+
+* Ensure cancelled uploads/downloads don't block queue
+
+## cs/v0.7.0-alpha.15 (2026-03-03)
+
+* Fix registry not removing objects when the removeAll call happens from the owner's deinit
+
+## cs/v0.7.0-alpha.14 (2026-03-02)
+
+* Improve the way drafts are considered non-resumable to pass through original exceptions
+
+## cs/v0.7.0-alpha.13 (2026-03-02)
+
+* Add Kotlin bindings for trash nodes
+* Test should not failed when SDK is aborted
+* Improve error reporting for trash and restore operations
+* Fix second-attempt file upload failing due to signature key disposal
+* Categorize upload integrity exception properly
+
+## cs/v0.7.0-alpha.12 (2026-02-25)
+
+* Transmit api codes through interop
+* Provide clearer context when canceling operations
+* Improve error reporting with full exception details
+* Clean native memory of weak references after release
+* Fix failures due to empty authorship results on degraded nodes
+* Clean native memory of global weak references
+* Upgrade android core to the last version (36.3.0)
+* Fix value type check
+* Set caller exception as cause to be reported in Sentry
+* Add context to timestamp conversion errors
+* Raise the timeout to 5min to upload 100MB file
+* Log progress as percentage
+* Accept null content key signatures
+
+## cs/v0.7.0-alpha.11 (2026-02-18)
+
+* Fix download of photos and their thumbnails from shared albums
+* Capture caller stack trace in ResponseCallback
+* Fix tranforming CompletedDownloadManifestVerificationException to...
+* Only set AEAD flag on file key creation
+
+## cs/v0.7.0-alpha.10 (2026-02-18)
+
+* Introduce callback handle registry, separate callback lifecycle from object lifecycle
+
+## cs/v0.7.0-alpha.9 (2026-02-17)
+
+* Expose errorToString
+* Fix deserialization of DegradedNode
+* Add E2E tests for photo thumbnails in albums
+
+## cs/v0.7.0-alpha.8 (2026-02-11)
+
+* Provide expected SHA1 for upload through callback
+* Refactor and fix support for Photos nodes
+
+## cs/v0.7.0-alpha.7 (2026-02-11)
+
+* Abort pause state on non-resumable upload errors
+* Exclude integrity errors from being resumable during upload
+
+## cs/v0.7.0-alpha.6 (2026-02-10)
+
+* Add SHA1 upload verification
+
+## cs/v0.7.0-alpha.5 (2026-02-05)
+
+* Log "is paused" state for download too
+* Check is controller is paused instead of looking at the domain error
+* Make author and signature verification error mutually exclusive in interop
+* Remove Photo from telemetry VolumeType
+* Add seek to photo download
+* Use SDK to get nodes in tests
+* Expose functions to get nodes and enumerate folder children through interop layer
+* Add photo upload and xAttr support to Swift bindings
+* Use unconfined dispatcher
+* Set coroutine context of operation and function to Dispatchers.IO
+* Rename Jni* methods to match proto requests
+
+## cs/v0.7.0-alpha.4 (2026-01-30)
+
+* Fix files being truncated when downloading to file path through interop
+* Follow up on download pausing to address issues with hanging, seeking with interop and telemetry
+* Fix timeout reported as cancellation through interop
+
+## cs/v0.7.0-alpha.3 (2026-01-27)
+
+* Transform progress callback to flow
+* Implement pausing and resuming of downloads
+* Add photos client kotlin bindings for upload
+* Handle and send decryption error telemetry to client
+* Enable request body streaming for upload
+
+## cs/v0.7.0-alpha.2 (2026-01-26)
+
+* Fix location of Photos project
+* Make cache optional
+* Log ignored errors
+* Add file upload methods to the Photos client
+* Replace stream with buffer for HTTP
+
+## cs/v0.7.0-alpha.1 (2026-01-23)
+
+* Enforce static code analysis warnings as errors on release builds
+* Replace stream by channel for thumbnails
+* Replace stream with channel
+* Add node metadata decryption error metrics
+* Fix native clients getting garbage collected during long request to the sdk
+* Add Kotlin tests for pausing and resuming downloads
+* Fix error not caught or returned to the sdk when scope was null
+* Add getThumbnails to DrivePhotosClient
+* Remove copyrights
+
+## cs/v0.6.1-alpha.17 (2026-01-20)
+
+* Fix errors not caught in Kotlin bindings and crashing client
+* Remove unnecessary parameter from .BeginTransaction calls
+
+## cs/v0.6.1-alpha.16 (2026-01-19)
+
+* Improve cache DB transaction locking behavior
+* Implement delayed cancellation for reading content during upload
+
+## cs/v0.6.1-alpha.15 (2026-01-16)
+
+* Adding Photos SDK bindings
+* Propagate encryption key via client configuration in swift bindings
+
+## cs/v0.6.1-alpha.14 (2026-01-16)
+
+* Improve on-disk cache handling
+* Update driveClientCreate to use ProtonDriveClientOptions and timeouts
+* Fix download photos from album
+* Add ability to override HTTP timeouts
+
+## cs/v0.6.1-alpha.13 (2026-01-15)
+
+* Fix build error due to missing brace in Protobuf definition
+* Implement support for protecting SDK databases
+* Expose functions to trash node through Swift package
+* refactor: consolidate PhotoDownloadOperation into DownloadOperation
+* Fix failure to resume upload that has gaps in block upload completions
+* Implement 429 handling for block downloads
+* Log paused status for each call
+* Expose folder creation in interop and Kotlin bindings
+* Update coroutine scope when resume
+* Introduce PhotoDownloadOperation
+* Simplify implementation for pausing uploads
+* Add Kotlin bindings for rename
+* Ignore cancellation error after cancelling in download test
+* Expose folder creation in interop and Swift bindings
+* Add support for photo decryption through album key packet
+
+## cs/v0.6.1-alpha.12 (2026-01-09)
+
+* Prevent download cancellation from blocking future downloads
+* Downloading empty file now report metric
+* Add Kotlin bindings for isPaused
+* Reduce network log level for tests from debug to verbose
+
+## cs/v0.6.1-alpha.11 (2026-01-08)
+
+* Fix builds for Kotlin and Swift bindings broken due to Experimental attribute
+* Handle 429 responses on block uploads
+
+## cs/v0.6.1-alpha.10 (2026-01-07)
+
+* Fix InteropStream length initialization for write streams
+* Implement initial photos client interop
+* Interop and bindings for DownloadController.GetIsDownloadCompleteWithVerificationIssue
+* Avoid logging storage body for test
+* Map download integrity exception to integrity domain for interop
+
+## cs/v0.6.1-alpha.9 (2026-01-06)
+
+* Pause upload on timeout
+* Fix progress logs in kotlin
+
+## cs/v0.6.1-alpha.8 (2026-01-04)
+
+* Switch to SQLite-free implementation for in-memory caching
+* Expose function to rename node through Swift package
+* Update download error handling
+* Limit GC pressure by creating less Channel instances
+* Add levels to logs
+
+## cs/v0.6.1-alpha.7 (2025-12-22)
+
+* Reapply removed upload controller dispose calls
+* Move incomplete draft deletion to upload controller disposal
+* Fix shares and share secrets not being cached
+* Expose download integrity errors and download status
+
+## cs/v0.6.1-alpha.6 (2025-12-19)
+
+* Fix download retrying on cancellation
+* Pass error when operation is paused to the client. Prevent crashes for calls after operation throws.
+
+## cs/v0.6.1-alpha.5 (2025-12-19)
+
+* Add cancellation message when CS cancels a job
+* Fix download failures due to missing keys for manifest check
+* Cancel CancellationTokenSource when coroutine scope is cancelled executing blocking function
+* Add photos thumbnail downloader
+* Update telemetry error mapping
+* Implement pausing and resuming of uploads
+* Fix exception on retrying thumbnail block upload
+* Add photo downloader
+* Add Photos client and Photos volume creation
+* Extract Job code from JniDriveClient
+* Test upload and download events
+* Convert stateless JNI methods to static
+* Log swallowed exceptions
+* Propagate exception to interop logger
+
+## cs/v0.6.1-alpha.4 (2025-12-15)
+
+* No changes
+
+## cs/v0.6.1-alpha.3 (2025-12-15)
+
+* Prefix the SDK static lib name for Swift with `lib`. Use non-macOS runner for SPM release.
+* Adds the pause, resume and isPaused calls to Swift bindings for upload and download
+
+## cs/v0.6.1-alpha.2 (2025-12-11)
+
+* No changes
+
+## cs/v0.6.1-alpha.1 (2025-12-11)
+
+* Fix build of Swift bindings on CI
+* Attach current thread only when detached
+* Reduce log level and normalize logs
+* Keep reference to logger provider in Kotlin test
+* Set error type to the name of the Kotlin exception
+* Improve error generation and parsing in Swift bindings
+* Check optional proto fields
+* Add properties to query paused state of upload and download
+* Prevent download from seeking back in output stream
+* Add error handling for writing to output stream
+* Add support to C# CLI for downloading by node UID
+* Increase number of attempts for block transfers
+* Remove debug log with fatal level
+
+## cs/v0.6.0-test.2 (2025-12-04)
+
+* No changes
+
+## cs/v0.6.0-alpha.7 (2025-12-10)
+
+* Set error type to the name of the Kotlin exception
+
+## cs/v0.6.0-alpha.6 (2025-12-10)
+
+* Improve error generation and parsing in Swift bindings
+
+## cs/v0.6.0-alpha.5 (2025-12-09)
+
+* Check optional proto fields
+* Add properties to query paused state of upload and download
+* Prevent download from seeking back in output stream
+* Add error handling for writing to output stream
+* Add support to C# CLI for downloading by node UID
+
+## cs/v0.6.0-alpha.4 (2025-12-05)
+
+* Increase number of attempts for block transfers
+* Remove debug log with fatal level
+
+## cs/v0.6.0-alpha.3 (2025-12-04)
+
+* Bump crypto lib to handle decrypted AEAD session key exports
+* Improve performance of iterating over URLSession.AsyncBytes during download
+* Handle degraded node
+
+## cs/v0.6.0-alpha.1 (2025-12-02)
+
+* Fix Kotlin build failure due to Protobuf changes
+* Implement telemetry for download
+* Fix crashes when download is interrupted
+* Add Kotlin bindings for feature flags
+* Remove unused parameter
+* Fix CLI resilience retrying even on successful round trips
+* Fix address verification happening too early
+* Include the Swift's error message in the SDK interop error
+* Add auto-retries into HTTP client bridge for certain HTTP errors: 401, 429, 5xx
+* Add HTTP timeouts and ability to cancel requests through interop
+* Handle diverging size on upload
+* Address security review of C# crypto
+* Preserve interop errors passing through SDK
+* Allow multiple calls to override native library name
+* Replace option to disable HTTP retries with a request type
+* Delay opening upload stream until necessery
+* Upgrade version from 0.4.0 to 0.5.0
+* Add hint to disable retries on HTTP requests
+* Close properly response body when read
+* Add more logging to transfer queues
+* Use streaming in HTTP client
+* Add AEAD support
+* Add approximate upload size to upload metric event in kt binding
+* Improve mapping of SDK exceptions to Kotlin errors
+* Add approximate upload size to upload metric event
+* Parse Protobuf request within the same JNI call
+* Support client-injected feature flags in Swift
+* Remove copyrights and optimize imports
+* Add filtering by type to thumbnail enumeration
+* Fix missing disposal of file uploader and file downloader through interop
+* Add pause and resume API
+* Add Kotlin bindings package for Android
+* Make feature flag provision asynchronous
+* Add feature flag support
+* Fix cancellation token source being double-freed in the Swift interop
+* Fix wrong additional metadata parameters in upload
+* Add possibility to provide additional metadata on file upload
+* Add method to download thumbnails
+* Pass node name conflict error data through interop
+* Fix blocks not being released during download
+* Expose cancellation support in SDK bindings
+* Add CI job to build and deploy Swift package
+* Update client creation through interop to be able to set client UID
+* Add telemetry for uploads
+* Expose function to get available node name through Swift package
+* Fix logger
+* Feat/parse error swift interop
+* Fix possibility of missing domain and type on interop errors
+* Fix missing SDK version header when injecting HTTP client without interop
+* Fix progress callback doesn't report issue
+* Fix thumbnails causing upload to hang
+* Fix deserialization error on getting available names
+* Add Swift SDK package for iOS & macOS
+* Fix download error due to misuse of new URL block fields
+* Fix error on HTTP response with Expires header when using interop
+* Fix deserialization error on download
+* Apply server time to PGP when injecting the HTTP client through interop
+* Improve logging and clean up some code
+* Fix SHA1 extended attribute
+* Align JSON output of the C# CLI with the JavaScript one
+* Fix conflicting draft deletion failure
+* Fix old revision UID being returned instead of new one after revision upload
+* Fix various interop issues found after enabling HTTP client injection
+
+## cs/v0.1.0-alpha.3 (2025-10-14)
+
+* Fix conflicting draft deletion failure
+* Fix old revision UID being returned instead of new one after revision upload
+* Fix thumbnail type enum
+* Allow logger provider handle for drive client creation
+* Add logging for upload and session
+* Make some naming clearer
+* Make thumbnail type strongly-typed in Protobufs
+* Fix exception when returning HTTP response through interop
+* Improve error message in case of invalid cast from interop handle
+
+## cs/0.6.0-alpha.3 (2025-12-04)
+
+* Bump crypto lib to handle decrypted AEAD session key exports
+* Improve performance of iterating over URLSession.AsyncBytes during download
+* Handle degraded node
+
+## cs/0.6.0-alpha.1 (2025-12-02)
+
+* Bump crypto lib to handle decrypted AEAD session key exports
+* Improve performance of iterating over URLSession.AsyncBytes during download
+* Handle degraded node
diff --git a/cs/Directory.Build.props b/cs/Directory.Build.props
new file mode 100644
index 00000000..2b73b042
--- /dev/null
+++ b/cs/Directory.Build.props
@@ -0,0 +1,92 @@
+
+
+
+ net10.0
+ true
+ true
+ false
+ true
+
+
+ true
+
+ Proton Drive
+ Proton AG
+ Proton AG
+ 0.0.1
+ © 2025 Proton AG
+ Proton Drive
+
+ enable
+ enable
+ en
+ true
+ true
+
+ lib
+
+
+
+ embedded
+ true
+
+
+
+ false
+
+
+
+ Exe
+ true
+ true
+ true
+ true
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/cs/Directory.Build.targets b/cs/Directory.Build.targets
new file mode 100644
index 00000000..875a2046
--- /dev/null
+++ b/cs/Directory.Build.targets
@@ -0,0 +1,9 @@
+
+
+
+ $(NETCoreSdkRuntimeIdentifier)
+ $(DefineConstants);WINDOWS
+ lib
+
+
+
diff --git a/cs/Directory.Packages.props b/cs/Directory.Packages.props
new file mode 100644
index 00000000..512b1897
--- /dev/null
+++ b/cs/Directory.Packages.props
@@ -0,0 +1,31 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cs/Proton.Drive.Sdk.slnx b/cs/Proton.Drive.Sdk.slnx
new file mode 100644
index 00000000..dcdc2765
--- /dev/null
+++ b/cs/Proton.Drive.Sdk.slnx
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cs/headers/module.modulemap b/cs/headers/module.modulemap
new file mode 100644
index 00000000..345cd13c
--- /dev/null
+++ b/cs/headers/module.modulemap
@@ -0,0 +1,4 @@
+module CProtonDriveSDK {
+ umbrella header "proton_drive_sdk.h"
+ export *
+}
diff --git a/cs/headers/proton_drive_sdk.h b/cs/headers/proton_drive_sdk.h
new file mode 100644
index 00000000..3af0c726
--- /dev/null
+++ b/cs/headers/proton_drive_sdk.h
@@ -0,0 +1,15 @@
+#ifndef PROTON_DRIVE_SDK_H
+#define PROTON_DRIVE_SDK_H
+
+#include
+#include
+
+#include "proton_sdk.h"
+
+void proton_drive_sdk_handle_request(
+ ByteArray request,
+ intptr_t bindings_handle,
+ array_action response_action
+);
+
+#endif // PROTON_DRIVE_SDK_H
diff --git a/cs/headers/proton_sdk.h b/cs/headers/proton_sdk.h
new file mode 100644
index 00000000..b2f55e7a
--- /dev/null
+++ b/cs/headers/proton_sdk.h
@@ -0,0 +1,30 @@
+#ifndef PROTON_SDK_H
+#define PROTON_SDK_H
+
+#include
+#include
+
+typedef struct {
+ const uint8_t* pointer;
+ size_t length;
+} ByteArray;
+
+typedef void array_action(intptr_t handle, ByteArray array);
+
+void override_native_library_name(
+ ByteArray library_name,
+ ByteArray overriding_library_name
+);
+
+void proton_sdk_handle_request(
+ ByteArray request,
+ intptr_t bindings_handle,
+ array_action response_action
+);
+
+void proton_sdk_handle_response(
+ intptr_t sdk_handle,
+ ByteArray response
+);
+
+#endif // PROTON_SDK_H
diff --git a/cs/nuget.config b/cs/nuget.config
new file mode 100644
index 00000000..12130aac
--- /dev/null
+++ b/cs/nuget.config
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cs/sdk/src/.globalconfig b/cs/sdk/src/.globalconfig
new file mode 100644
index 00000000..112c82f1
--- /dev/null
+++ b/cs/sdk/src/.globalconfig
@@ -0,0 +1,2 @@
+# For library projects, ConfigureAwait(false) is strongly recommended
+dotnet_diagnostic.CA2007.severity = warning
\ No newline at end of file
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs
new file mode 100644
index 00000000..3f322143
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/DriveInteropTelemetryDecorator.cs
@@ -0,0 +1,186 @@
+using System.Net;
+using Google.Protobuf;
+using Microsoft.Extensions.Logging;
+using Proton.Drive.Sdk.Telemetry;
+using Proton.Sdk.CExports;
+using Proton.Sdk.Http;
+using Proton.Sdk.Telemetry;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal sealed class DriveInteropTelemetryDecorator(InteropTelemetry instanceToDecorate) : ITelemetry
+{
+ private readonly InteropTelemetry _instanceToDecorate = instanceToDecorate;
+
+ public ILogger GetLogger(string name)
+ {
+ return _instanceToDecorate.GetLogger(name);
+ }
+
+ public void RecordMetric(IMetricEvent metricEvent)
+ {
+ IMessage? payload = metricEvent switch
+ {
+ UploadEvent me => GetUploadEventPayload(me),
+ DownloadEvent me => GetDownloadEventPayload(me),
+ DecryptionErrorEvent me => GetDecryptionErrorPayload(me),
+ BlockVerificationErrorEvent me => GetBlockVerificationErrorPayload(me),
+ _ => null,
+ };
+
+ if (payload is null)
+ {
+ _instanceToDecorate.RecordMetric(metricEvent);
+ return;
+ }
+
+ _instanceToDecorate.RecordMetric(metricEvent.Name, payload);
+ }
+
+ private static UploadEventPayload GetUploadEventPayload(UploadEvent me)
+ {
+ var payload = new UploadEventPayload
+ {
+ VolumeType = (VolumeType)me.VolumeType,
+ UploadedSize = me.UploadedSize,
+ ApproximateUploadedSize = me.ApproximateUploadedSize,
+ ExpectedSize = me.ExpectedSize,
+ ApproximateExpectedSize = me.ApproximateExpectedSize,
+ };
+
+ // Check if we should translate InteropErrorException when error is Unknown
+ var error = me is { Error: Telemetry.UploadError.Unknown, OriginalError: InteropErrorException interopError }
+ ? TranslateToUploadError(interopError)
+ : me.Error;
+
+ if (error is not null)
+ {
+ payload.Error = (UploadError)error;
+ }
+
+ if (me.OriginalError is not null)
+ {
+ payload.OriginalError = me.OriginalError.GetBaseException().ToString();
+ }
+
+ return payload;
+ }
+
+ private static DownloadEventPayload GetDownloadEventPayload(DownloadEvent me)
+ {
+ var payload = new DownloadEventPayload
+ {
+ VolumeType = (VolumeType)me.VolumeType,
+ DownloadedSize = me.DownloadedSize,
+ ApproximateDownloadedSize = me.ApproximateDownloadedSize,
+ ClaimedFileSize = me.ClaimedFileSize ?? 0,
+ ApproximateClaimedFileSize = me.ApproximateClaimedFileSize ?? 0,
+ };
+
+ // Check if we should translate InteropErrorException when error is Unknown
+ var error = me is { Error: Telemetry.DownloadError.Unknown, OriginalError: InteropErrorException interopError }
+ ? TranslateToDownloadError(interopError)
+ : me.Error;
+
+ if (error is not null)
+ {
+ payload.Error = (DownloadError)error;
+ }
+
+ if (me.OriginalError is not null)
+ {
+ payload.OriginalError = me.OriginalError.GetBaseException().ToString();
+ }
+
+ return payload;
+ }
+
+ private static BlockVerificationErrorEventPayload GetBlockVerificationErrorPayload(BlockVerificationErrorEvent me)
+ {
+ return new BlockVerificationErrorEventPayload
+ {
+ VolumeType = (VolumeType)me.VolumeType,
+ RetryHelped = me.RetryHelped,
+ };
+ }
+
+ private static DecryptionErrorEventPayload GetDecryptionErrorPayload(DecryptionErrorEvent me)
+ {
+ var payload = new DecryptionErrorEventPayload
+ {
+ VolumeType = (VolumeType)me.VolumeType,
+ Field = (EncryptedField)me.Field,
+ Uid = me.Uid.ToString(),
+ };
+
+ if (me.FromBefore2024.HasValue)
+ {
+ payload.FromBefore2024 = me.FromBefore2024.Value;
+ }
+
+ if (me.Error is not null)
+ {
+ payload.Error = me.Error;
+ }
+
+ return payload;
+ }
+
+ private static Telemetry.UploadError? TranslateToUploadError(InteropErrorException exception)
+ {
+ if (exception.Error is null)
+ {
+ return Telemetry.UploadError.Unknown;
+ }
+
+ var error = exception.Error;
+ return exception.Error.Domain switch
+ {
+ ErrorDomain.Api => TranslateApiErrorToUploadError(error.SecondaryCode),
+ ErrorDomain.Network or ErrorDomain.Transport => Telemetry.UploadError.NetworkError,
+ ErrorDomain.Serialization => Telemetry.UploadError.HttpClientSideError,
+ ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Telemetry.UploadError.IntegrityError,
+ ErrorDomain.BusinessLogic => Telemetry.UploadError.ValidationError,
+ _ => Telemetry.UploadError.Unknown,
+ };
+ }
+
+ private static Telemetry.UploadError TranslateApiErrorToUploadError(long statusCode)
+ {
+ return statusCode switch
+ {
+ (int)HttpStatusCode.TooManyRequests => Telemetry.UploadError.RateLimited,
+ >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode => Telemetry.UploadError.HttpClientSideError,
+ _ => Telemetry.UploadError.ServerError,
+ };
+ }
+
+ private static Telemetry.DownloadError? TranslateToDownloadError(InteropErrorException exception)
+ {
+ if (exception.Error is null)
+ {
+ return Telemetry.DownloadError.Unknown;
+ }
+
+ var error = exception.Error;
+ return exception.Error.Domain switch
+ {
+ ErrorDomain.Api => TranslateApiErrorToDownloadError(error.SecondaryCode),
+ ErrorDomain.Network or ErrorDomain.Transport => Telemetry.DownloadError.NetworkError,
+ ErrorDomain.Serialization => Telemetry.DownloadError.HttpClientSideError,
+ ErrorDomain.Cryptography or ErrorDomain.DataIntegrity => Telemetry.DownloadError.IntegrityError,
+ ErrorDomain.BusinessLogic => Telemetry.DownloadError.ValidationError,
+ _ => Telemetry.DownloadError.Unknown,
+ };
+ }
+
+ private static Telemetry.DownloadError TranslateApiErrorToDownloadError(long statusCode)
+ {
+ return statusCode switch
+ {
+ (int)HttpStatusCode.TooManyRequests => Telemetry.DownloadError.RateLimited,
+ >= StatusCodes.MinClientErrorCode and <= StatusCodes.MaxClientErrorCode => Telemetry.DownloadError.HttpClientSideError,
+ _ => Telemetry.DownloadError.ServerError,
+ };
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs
new file mode 100644
index 00000000..3674e2c8
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropAccountClient.cs
@@ -0,0 +1,77 @@
+using Google.Protobuf.WellKnownTypes;
+using Proton.Cryptography.Pgp;
+using Proton.Sdk.Addresses;
+using Proton.Sdk.CExports;
+using Address = Proton.Sdk.Addresses.Address;
+using AddressKey = Proton.Sdk.Addresses.AddressKey;
+using AddressStatus = Proton.Sdk.Addresses.AddressStatus;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal sealed class InteropAccountClient(nint bindingsHandle, InteropAction, nint> requestAction) : IAccountClient
+{
+ private readonly nint _bindingsHandle = bindingsHandle;
+ private readonly InteropAction, nint> _requestAction = requestAction;
+
+ public async ValueTask GetAddressAsync(AddressId addressId, CancellationToken cancellationToken)
+ {
+ var request = new AccountRequest { GetAddress = new GetAddressRequest { AddressId = addressId.ToString() } };
+ var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false);
+
+ return ConvertToAddress(response);
+ }
+
+ public async ValueTask GetDefaultAddressAsync(CancellationToken cancellationToken)
+ {
+ var response = await _requestAction.SendRequestAsync(
+ _bindingsHandle,
+ new AccountRequest { GetDefaultAddress = new GetDefaultAddressRequest() }).ConfigureAwait(false);
+
+ return ConvertToAddress(response);
+ }
+
+ public async ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken)
+ {
+ var request = new AccountRequest { GetAddressPrimaryPrivateKey = new GetAddressPrimaryPrivateKeyRequest { AddressId = addressId.ToString() } };
+ var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false);
+
+ return PgpPrivateKey.Import(response.Value.Span);
+ }
+
+ public async ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken)
+ {
+ var request = new AccountRequest { GetAddressPrivateKeys = new GetAddressPrivateKeysRequest { AddressId = addressId.ToString() } };
+ var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false);
+
+ return [.. response.Value.Select(keyData => PgpPrivateKey.Import(keyData.Span))];
+ }
+
+ public async ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken)
+ {
+ var request = new AccountRequest { GetAddressPublicKeys = new GetAddressPublicKeysRequest { EmailAddress = emailAddress } };
+ var response = await _requestAction.SendRequestAsync(_bindingsHandle, request).ConfigureAwait(false);
+
+ return [.. response.Value.Select(keyData => PgpPublicKey.Import(keyData.Span))];
+ }
+
+ private static Address ConvertToAddress(Proton.Sdk.CExports.Address addressMessage)
+ {
+ var addressId = new AddressId(addressMessage.AddressId);
+
+ var keys = addressMessage.Keys.Select((key, index) => new AddressKey(
+ addressId,
+ new AddressKeyId(key.AddressKeyId),
+ index == addressMessage.PrimaryKeyIndex,
+ key.IsActive,
+ key.IsAllowedForEncryption,
+ key.IsAllowedForVerification)).ToList();
+
+ return new Address(
+ addressId,
+ addressMessage.Order,
+ addressMessage.EmailAddress,
+ (AddressStatus)addressMessage.Status,
+ keys,
+ addressMessage.PrimaryKeyIndex);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs
new file mode 100644
index 00000000..13df84dd
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropActionExtensions.cs
@@ -0,0 +1,27 @@
+using Google.Protobuf;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropActionExtensions
+{
+ public static unsafe void InvokeProgressUpdate(this InteropAction> interopAction, nint bindingsHandle, long progress, long? total)
+ {
+ var progressUpdate = new ProgressUpdate
+ {
+ BytesCompleted = progress,
+ };
+
+ if (total is not null)
+ {
+ progressUpdate.BytesInTotal = total.Value;
+ }
+
+ var requestBytes = progressUpdate.ToByteArray();
+
+ fixed (byte* requestBytesPointer = requestBytes)
+ {
+ interopAction.Invoke(bindingsHandle, new InteropArray(requestBytesPointer, requestBytes.Length));
+ }
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs
new file mode 100644
index 00000000..9ba82aba
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropConversionExtensions.cs
@@ -0,0 +1,261 @@
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes;
+using Proton.Sdk;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropConversionExtensions
+{
+ extension(Nodes.Node node)
+ {
+ public Node ToInterop()
+ {
+ var result = new Node();
+
+ switch (node)
+ {
+ case Nodes.FolderNode folderNode:
+ result.Folder = Nodes.Node.ToInterop(folderNode);
+ break;
+ case Nodes.FileNode fileNode:
+ result.File = Nodes.Node.ToInterop(fileNode);
+ break;
+ }
+
+ return result;
+ }
+
+ private static FolderNode ToInterop(Nodes.FolderNode folderNode)
+ {
+ var folderNodeProto = new FolderNode
+ {
+ Uid = folderNode.Uid.ToString(),
+ TreeEventScopeId = folderNode.TreeEventScopeId,
+ Name = folderNode.Name.ToInterop(),
+ CreationTime = folderNode.CreationTime.ToUniversalTime().ToTimestamp(),
+ TrashTime = folderNode.TrashTime?.ToUniversalTime().ToTimestamp(),
+ NameAuthor = folderNode.NameAuthor.ToInterop(),
+ Author = folderNode.Author.ToInterop(),
+ OwnedBy = folderNode.OwnedBy.ToInterop(),
+ };
+
+ if (folderNode.ParentUid != null)
+ {
+ folderNodeProto.ParentUid = folderNode.ParentUid.ToString();
+ }
+
+ folderNodeProto.Errors.AddRange(folderNode.Errors.Select(ToInterop));
+
+ return folderNodeProto;
+ }
+
+ private static FileNode ToInterop(Nodes.FileNode fileNode)
+ {
+ var fileNodeProto = new FileNode
+ {
+ Uid = fileNode.Uid.ToString(),
+ TreeEventScopeId = fileNode.TreeEventScopeId,
+ Name = fileNode.Name.ToInterop(),
+ MediaType = fileNode.MediaType,
+ CreationTime = fileNode.CreationTime.ToUniversalTime().ToTimestamp(),
+ TrashTime = fileNode.TrashTime?.ToUniversalTime().ToTimestamp(),
+ NameAuthor = fileNode.NameAuthor.ToInterop(),
+ Author = fileNode.Author.ToInterop(),
+ TotalSizeOnCloudStorage = fileNode.TotalSizeOnCloudStorage,
+ OwnedBy = fileNode.OwnedBy.ToInterop(),
+ };
+
+ if (fileNode.ParentUid != null)
+ {
+ fileNodeProto.ParentUid = fileNode.ParentUid.ToString();
+ }
+
+ fileNodeProto.ActiveRevision = fileNode.ActiveRevision.ToInterop();
+
+ fileNodeProto.Errors.AddRange(fileNode.Errors.Select(ToInterop));
+
+ return fileNodeProto;
+ }
+ }
+
+ extension(ProtonDriveError error)
+ {
+ public DriveError ToInterop()
+ {
+ var driveError = new DriveError
+ {
+ InnerError = error.InnerError?.ToInterop(),
+ };
+
+ if (error.Message != null)
+ {
+ driveError.Message = error.Message;
+ }
+
+ return driveError;
+ }
+ }
+
+ extension(IReadOnlyDictionary> results)
+ {
+ public NodeResultListResponse ToInterop()
+ {
+ return new NodeResultListResponse
+ {
+ Results =
+ {
+ results.Select(pair =>
+ {
+ var result = new NodeResultPair
+ {
+ NodeUid = pair.Key.ToString(),
+ };
+
+ if (pair.Value.TryGetError(out var exception))
+ {
+ result.Error = exception.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes);
+ }
+
+ return result;
+ }),
+ },
+ };
+ }
+ }
+
+ extension(Revision revision)
+ {
+ public FileRevision ToInterop()
+ {
+ var protoRevision = new FileRevision
+ {
+ Uid = revision.Uid.ToString(),
+ CreationTime = revision.CreationTime.ToUniversalTime().ToTimestamp(),
+ SizeOnCloudStorage = revision.SizeOnCloudStorage,
+ ClaimedSize = revision.ClaimedSize ?? 0,
+ ClaimedModificationTime = revision.ClaimedModificationTime?.ToUniversalTime().ToTimestamp(),
+ };
+
+ if (revision.ClaimedDigests is { } claimedDigests)
+ {
+ protoRevision.ClaimedDigests = new FileContentDigests
+ {
+ Sha1Verified = claimedDigests.Sha1Verified,
+ };
+
+ if (claimedDigests.Sha1 is { } sha1)
+ {
+ protoRevision.ClaimedDigests.Sha1 = ByteString.CopyFrom(sha1.Span);
+ }
+ }
+
+ protoRevision.Thumbnails.AddRange(
+ revision.Thumbnails.Select(t => new ThumbnailHeader
+ {
+ Id = t.Id,
+ Type = (ThumbnailType)(int)t.Type,
+ }));
+
+ if (revision.AdditionalClaimedMetadata is not null)
+ {
+ protoRevision.AdditionalClaimedMetadata.AddRange(
+ revision.AdditionalClaimedMetadata.Select(m => new AdditionalMetadataProperty
+ {
+ Name = m.Name,
+ Utf8JsonValue = ByteString.CopyFromUtf8(m.Value.ToString()),
+ }));
+ }
+
+ if (revision.ContentAuthor.HasValue)
+ {
+ protoRevision.ContentAuthor = revision.ContentAuthor.Value.ToInterop();
+ }
+
+ return protoRevision;
+ }
+ }
+
+ extension(Result result)
+ {
+ public AuthorResult ToInterop()
+ {
+ var authorResult = new AuthorResult();
+
+ if (result.TryGetValueElseError(out var author, out var error))
+ {
+ var authorResultValue = new Author();
+ if (authorResultValue.EmailAddress != null)
+ {
+ authorResultValue.EmailAddress = author.EmailAddress;
+ }
+
+ authorResult.Value = authorResultValue;
+ }
+ else
+ {
+ var claimedAuthor = new Author();
+ if (error.ClaimedAuthor.EmailAddress != null)
+ {
+ claimedAuthor.EmailAddress = error.ClaimedAuthor.EmailAddress;
+ }
+
+ authorResult.Error = new SignatureVerificationError
+ {
+ ClaimedAuthor = claimedAuthor,
+ };
+
+ if (error.Message != null)
+ {
+ // TODO change message to be a DriveError
+ authorResult.Error.Message = error.FlattenMessage();
+ }
+ }
+
+ return authorResult;
+ }
+ }
+
+ extension(Nodes.OwnedBy? ownedBy)
+ {
+ public OwnedBy ToInterop()
+ {
+ if (ownedBy is null)
+ {
+ return new OwnedBy();
+ }
+
+ var result = new OwnedBy();
+ if (ownedBy.Email != null)
+ {
+ result.Email = ownedBy.Email;
+ }
+
+ if (ownedBy.Organization != null)
+ {
+ result.Organization = ownedBy.Organization;
+ }
+
+ return result;
+ }
+ }
+
+ extension(Result result)
+ {
+ public StringResult ToInterop()
+ {
+ var stringResult = new StringResult();
+ if (result.TryGetValueElseError(out var value, out var error))
+ {
+ stringResult.Value = value;
+ }
+ else
+ {
+ stringResult.Error = error.ToInterop();
+ }
+
+ return stringResult;
+ }
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs
new file mode 100644
index 00000000..26894c73
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDownloadController.cs
@@ -0,0 +1,59 @@
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes.Download;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropDownloadController
+{
+ public static IMessage HandleIsPaused(DownloadControllerIsPausedRequest request)
+ {
+ var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle);
+
+ return new BoolValue { Value = downloadController.IsPaused };
+ }
+
+ public static async ValueTask HandleAwaitCompletion(DownloadControllerAwaitCompletionRequest request)
+ {
+ var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle);
+
+ await downloadController.Completion.ConfigureAwait(false);
+
+ return null;
+ }
+
+ public static IMessage? HandlePause(DownloadControllerPauseRequest request)
+ {
+ var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle);
+
+ downloadController.Pause();
+
+ return null;
+ }
+
+ public static IMessage? HandleResume(DownloadControllerResumeRequest request)
+ {
+ var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle);
+
+ downloadController.Resume();
+
+ return null;
+ }
+
+ public static IMessage? HandleIsDownloadCompleteWithVerificationIssue(DownloadControllerIsDownloadCompleteWithVerificationIssueRequest request)
+ {
+ var downloadController = Interop.GetFromHandle(request.DownloadControllerHandle);
+
+ return new BoolValue { Value = downloadController.GetIsDownloadCompleteWithVerificationIssue() };
+ }
+
+ public static async ValueTask HandleFree(DownloadControllerFreeRequest request)
+ {
+ var downloadController = Interop.FreeHandle(request.DownloadControllerHandle);
+
+ await downloadController.DisposeAsync().ConfigureAwait(false);
+
+ return null;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs
new file mode 100644
index 00000000..aa3bcd55
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveErrorConverter.cs
@@ -0,0 +1,192 @@
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes;
+using Proton.Drive.Sdk.Nodes.Download;
+using Proton.Drive.Sdk.Nodes.Upload;
+using Proton.Drive.Sdk.Nodes.Upload.Verification;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropDriveErrorConverter
+{
+ private const int UnknownDecryptionErrorPrimaryCode = 0;
+ private const int NodeMetadataDecryptionErrorPrimaryCode = 2;
+ private const int FileContentsDecryptionErrorPrimaryCode = 3;
+ private const int UploadKeyMismatchErrorPrimaryCode = 4;
+ private const int ManifestSignatureVerificationErrorPrimaryCode = 5;
+ private const int ContentUploadIntegrityErrorPrimaryCode = 6;
+
+ public static void SetDomainAndCodes(Error error, Exception exception)
+ {
+ switch (exception)
+ {
+ case NodeMetadataDecryptionException e:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = NodeMetadataDecryptionErrorPrimaryCode;
+ error.SecondaryCode = (long)e.Part;
+ break;
+
+ case FileContentsDecryptionException:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = FileContentsDecryptionErrorPrimaryCode;
+ break;
+
+ case NodeKeyAndSessionKeyMismatchException:
+ case SessionKeyAndDataPacketMismatchException:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = UploadKeyMismatchErrorPrimaryCode;
+ break;
+
+ case DataIntegrityException:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = ManifestSignatureVerificationErrorPrimaryCode;
+ break;
+
+ case MissingContentBlockIntegrityException e:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode;
+ error.AdditionalData = Any.Pack(ToAdditionalData(e));
+ break;
+
+ case ContentSizeMismatchIntegrityException e:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode;
+ error.AdditionalData = Any.Pack(ToAdditionalData(e));
+ break;
+
+ case ThumbnailCountMismatchIntegrityException e:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode;
+ error.AdditionalData = Any.Pack(ToAdditionalData(e));
+ break;
+
+ case ChecksumMismatchIntegrityException e:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode;
+ error.AdditionalData = Any.Pack(ToAdditionalData(e));
+ break;
+
+ case IntegrityException:
+ error.Domain = ErrorDomain.DataIntegrity;
+ error.PrimaryCode = ContentUploadIntegrityErrorPrimaryCode;
+ break;
+
+ case NodeWithSameNameExistsException e:
+ if (e.Code is not null)
+ {
+ error.PrimaryCode = (long)e.Code.Value;
+ }
+
+ error.Domain = ErrorDomain.BusinessLogic;
+ error.AdditionalData = Any.Pack(ToAdditionalData(e));
+ break;
+
+ case NodeNotFoundException e:
+ if (e.Code is not null)
+ {
+ error.PrimaryCode = (long)e.Code.Value;
+ }
+
+ error.Domain = ErrorDomain.BusinessLogic;
+ error.AdditionalData = Any.Pack(ToAdditionalData(e));
+ break;
+
+ default:
+ error.PrimaryCode = UnknownDecryptionErrorPrimaryCode;
+ InteropErrorConverter.SetDomainAndCodes(error, exception);
+ break;
+ }
+ }
+
+ private static MissingContentBlockErrorData ToAdditionalData(MissingContentBlockIntegrityException e)
+ {
+ var data = new MissingContentBlockErrorData();
+ if (e.BlockNumber is { } blockNumber)
+ {
+ data.BlockNumber = blockNumber;
+ }
+
+ return data;
+ }
+
+ private static ContentSizeMismatchErrorData ToAdditionalData(ContentSizeMismatchIntegrityException e)
+ {
+ var data = new ContentSizeMismatchErrorData();
+ if (e.UploadedSize is { } uploadedSize)
+ {
+ data.UploadedSize = uploadedSize;
+ }
+
+ if (e.ExpectedSize is { } expectedSize)
+ {
+ data.ExpectedSize = expectedSize;
+ }
+
+ return data;
+ }
+
+ private static ThumbnailCountMismatchErrorData ToAdditionalData(ThumbnailCountMismatchIntegrityException e)
+ {
+ var data = new ThumbnailCountMismatchErrorData();
+ if (e.UploadedBlockCount is { } uploadedBlockCount)
+ {
+ data.UploadedBlockCount = uploadedBlockCount;
+ }
+
+ if (e.ExpectedBlockCount is { } expectedBlockCount)
+ {
+ data.ExpectedBlockCount = expectedBlockCount;
+ }
+
+ return data;
+ }
+
+ private static ChecksumMismatchErrorData ToAdditionalData(ChecksumMismatchIntegrityException e)
+ {
+ var data = new ChecksumMismatchErrorData();
+ if (e.ActualChecksum is not null)
+ {
+ data.ActualChecksum = ByteString.CopyFrom(e.ActualChecksum);
+ }
+
+ if (e.ExpectedChecksum is not null)
+ {
+ data.ExpectedChecksum = ByteString.CopyFrom(e.ExpectedChecksum);
+ }
+
+ return data;
+ }
+
+ private static NodeNameConflictErrorData ToAdditionalData(NodeWithSameNameExistsException e)
+ {
+ var data = new NodeNameConflictErrorData();
+ if (e.ConflictingNodeIsFileDraft is { } conflictingNodeIsFileDraft)
+ {
+ data.ConflictingNodeIsFileDraft = conflictingNodeIsFileDraft;
+ }
+
+ if (e.ConflictingNodeUid is { } conflictingNodeUid)
+ {
+ data.ConflictingNodeUid = conflictingNodeUid.ToString();
+ }
+
+ if (e.ConflictingRevisionUid is { } conflictingRevisionUid)
+ {
+ data.ConflictingRevisionUid = conflictingRevisionUid.ToString();
+ }
+
+ return data;
+ }
+
+ private static NodeNotFoundErrorData ToAdditionalData(NodeNotFoundException e)
+ {
+ var data = new NodeNotFoundErrorData();
+ if (e.NodeUid is { } nodeUid)
+ {
+ data.NodeUid = nodeUid.ToString();
+ }
+
+ return data;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveProtobufMetadata.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveProtobufMetadata.cs
new file mode 100644
index 00000000..ce50b909
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropDriveProtobufMetadata.cs
@@ -0,0 +1,16 @@
+using System.Text.Json;
+using Google.Protobuf.Collections;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropDriveProtobufMetadata
+{
+ internal static IEnumerable? ParseAdditionalMetadata(
+ RepeatedField additionalMetadata) =>
+ additionalMetadata.Count > 0
+ ? additionalMetadata.Select(x =>
+ new Proton.Drive.Sdk.Nodes.AdditionalMetadataProperty(
+ x.Name,
+ JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement))
+ : null;
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs
new file mode 100644
index 00000000..6f7bb904
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileDownloader.cs
@@ -0,0 +1,59 @@
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes.Download;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropFileDownloader
+{
+ public static IMessage HandleDownloadToStream(DownloadToStreamRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var downloader = Interop.GetFromHandle(request.DownloaderHandle);
+
+ var writeFunction = new InteropFunction, nint, nint>(request.WriteAction);
+
+ var seekAction = request.SeekAction != 0
+ ? new InteropAction, nint>(request.SeekAction)
+ : (InteropAction, nint>?)null;
+
+ var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null;
+ var stream = new InteropStream(bindingsHandle, writeFunction, seekAction, cancelAction);
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+
+ var downloadController = downloader.DownloadToStream(
+ stream,
+ (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal),
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(downloadController) };
+ }
+
+ public static IMessage HandleDownloadToFile(DownloadToFileRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var downloader = Interop.GetFromHandle(request.DownloaderHandle);
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+
+ var downloadController = downloader.DownloadToFile(
+ request.FilePath,
+ (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal),
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(downloadController) };
+ }
+
+ public static IMessage? HandleFree(FileDownloaderFreeRequest request)
+ {
+ var fileDownloader = Interop.FreeHandle(request.FileDownloaderHandle);
+
+ fileDownloader.Dispose();
+
+ return null;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs
new file mode 100644
index 00000000..529a3b54
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropFileUploader.cs
@@ -0,0 +1,103 @@
+using System.Security.Cryptography;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes.Upload;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropFileUploader
+{
+ public static IMessage HandleUploadFromStream(UploadFromStreamRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var uploader = Interop.GetFromHandle(request.UploaderHandle);
+
+ var readFunction = new InteropFunction, nint, nint>(request.ReadAction);
+ var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null;
+ var stream = new InteropStream(uploader.FileSize, bindingsHandle, readFunction, cancelAction);
+
+ var thumbnails = request.Thumbnails.Select(t =>
+ {
+ unsafe
+ {
+ var thumbnailType = (Nodes.ThumbnailType)t.Type;
+ return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray());
+ }
+ });
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+
+ var expectedSha1Provider = request.HasSha1Function ? CreateSha1Provider(bindingsHandle, request.Sha1Function) : null;
+
+ var uploadController = uploader.UploadFromStream(
+ stream,
+ thumbnails,
+ (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total),
+ expectedSha1Provider,
+ forPhotos: false,
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(uploadController) };
+ }
+
+ public static IMessage HandleUploadFromFile(UploadFromFileRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var uploader = Interop.GetFromHandle(request.UploaderHandle);
+
+ var thumbnails = request.Thumbnails.Select(t =>
+ {
+ unsafe
+ {
+ var thumbnailType = (Nodes.ThumbnailType)t.Type;
+ return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray());
+ }
+ });
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+
+ var expectedSha1Provider = request.HasSha1Function ? CreateSha1Provider(bindingsHandle, request.Sha1Function) : null;
+
+ var uploadController = uploader.UploadFromFile(
+ request.FilePath,
+ thumbnails,
+ (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total),
+ expectedSha1Provider,
+ forPhotos: false,
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(uploadController) };
+ }
+
+ public static IMessage? HandleFree(FileUploaderFreeRequest request)
+ {
+ var fileUploader = Interop.FreeHandle(request.FileUploaderHandle);
+
+ fileUploader.Dispose();
+
+ return null;
+ }
+
+ internal static Func> CreateSha1Provider(nint bindingsHandle, long functionPointer)
+ {
+ return () =>
+ {
+ var sha1Buffer = new byte[SHA1.HashSizeInBytes];
+
+ unsafe
+ {
+ fixed (byte* sha1BufferPointer = sha1Buffer)
+ {
+ var function = new InteropAction>(functionPointer);
+
+ function.Invoke(bindingsHandle, new InteropArray(sha1BufferPointer, sha1Buffer.Length));
+ }
+ }
+
+ return sha1Buffer;
+ };
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs
new file mode 100644
index 00000000..091b784b
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropMessageHandler.cs
@@ -0,0 +1,207 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropMessageHandler
+{
+ [UnmanagedCallersOnly(EntryPoint = "proton_drive_sdk_handle_request", CallConvs = [typeof(CallConvCdecl)])]
+ public static async void OnRequestReceived(InteropArray requestBytes, nint bindingsHandle, InteropAction> responseAction)
+ {
+ try
+ {
+ var request = Request.Parser.ParseFrom(requestBytes.AsReadOnlySpan());
+
+ var response = request.PayloadCase switch
+ {
+ Request.PayloadOneofCase.DriveClientCreate
+ => InteropProtonDriveClient.HandleCreate(request.DriveClientCreate, bindingsHandle),
+
+ Request.PayloadOneofCase.DriveClientCreateFromSession
+ => InteropProtonDriveClient.HandleCreate(request.DriveClientCreateFromSession),
+
+ Request.PayloadOneofCase.DriveClientFree
+ => InteropProtonDriveClient.HandleFree(request.DriveClientFree),
+
+ Request.PayloadOneofCase.DriveClientGetFileUploader
+ => await InteropProtonDriveClient.HandleGetFileUploaderAsync(request.DriveClientGetFileUploader).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientGetFileRevisionUploader
+ => await InteropProtonDriveClient.HandleGetFileRevisionUploaderAsync(request.DriveClientGetFileRevisionUploader).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientGetFileDownloader
+ => await InteropProtonDriveClient.HandleGetFileDownloaderAsync(request.DriveClientGetFileDownloader).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientGetAvailableName
+ => await InteropProtonDriveClient.HandleGetAvailableNameAsync(request.DriveClientGetAvailableName).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientTrashNodes
+ => await InteropProtonDriveClient.HandleTrashNodesAsync(request.DriveClientTrashNodes).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientDeleteNodes
+ => await InteropProtonDriveClient.HandleDeleteNodesAsync(request.DriveClientDeleteNodes).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientRestoreNodes
+ => await InteropProtonDriveClient.HandleRestoreNodesAsync(request.DriveClientRestoreNodes).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientEnumerateTrash
+ => await InteropProtonDriveClient.HandleEnumerateTrashAsync(request.DriveClientEnumerateTrash, bindingsHandle).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientEmptyTrash
+ => await InteropProtonDriveClient.HandleEmptyTrashAsync(request.DriveClientEmptyTrash).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientRename
+ => await InteropProtonDriveClient.HandleRenameAsync(request.DriveClientRename).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientCreateFolder
+ => await InteropProtonDriveClient.HandleCreateFolderAsync(request.DriveClientCreateFolder).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientEnumerateThumbnails
+ => await InteropProtonDriveClient.HandleEnumerateThumbnailsAsync(
+ request.DriveClientEnumerateThumbnails,
+ bindingsHandle).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientEnumerateFolderChildren
+ => await InteropProtonDriveClient.HandleEnumerateFolderChildrenAsync(
+ request.DriveClientEnumerateFolderChildren,
+ bindingsHandle).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientGetMyFilesFolder
+ => await InteropProtonDriveClient.HandleGetMyFilesFolderAsync(request.DriveClientGetMyFilesFolder).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DriveClientGetNode
+ => await InteropProtonDriveClient.HandleGetNodeAsync(request.DriveClientGetNode).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.UploadFromStream
+ => InteropFileUploader.HandleUploadFromStream(request.UploadFromStream, bindingsHandle),
+
+ Request.PayloadOneofCase.UploadFromFile
+ => InteropFileUploader.HandleUploadFromFile(request.UploadFromFile, bindingsHandle),
+
+ Request.PayloadOneofCase.FileUploaderFree
+ => InteropFileUploader.HandleFree(request.FileUploaderFree),
+
+ Request.PayloadOneofCase.UploadControllerIsPaused
+ => InteropUploadController.HandleIsPaused(request.UploadControllerIsPaused),
+
+ Request.PayloadOneofCase.UploadControllerAwaitCompletion
+ => await InteropUploadController.HandleAwaitCompletion(request.UploadControllerAwaitCompletion).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.UploadControllerPause
+ => InteropUploadController.HandlePause(request.UploadControllerPause),
+
+ Request.PayloadOneofCase.UploadControllerResume
+ => InteropUploadController.HandleResume(request.UploadControllerResume),
+
+ Request.PayloadOneofCase.UploadControllerDispose
+ => await InteropUploadController.HandleDisposeAsync(request.UploadControllerDispose).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.UploadControllerFree
+ => InteropUploadController.HandleFree(request.UploadControllerFree),
+
+ Request.PayloadOneofCase.DownloadToStream
+ => InteropFileDownloader.HandleDownloadToStream(request.DownloadToStream, bindingsHandle),
+
+ Request.PayloadOneofCase.DownloadToFile
+ => InteropFileDownloader.HandleDownloadToFile(request.DownloadToFile, bindingsHandle),
+
+ Request.PayloadOneofCase.FileDownloaderFree
+ => InteropFileDownloader.HandleFree(request.FileDownloaderFree),
+
+ Request.PayloadOneofCase.DownloadControllerIsPaused
+ => InteropDownloadController.HandleIsPaused(request.DownloadControllerIsPaused),
+
+ Request.PayloadOneofCase.DownloadControllerIsDownloadCompleteWithVerificationIssue
+ => InteropDownloadController.HandleIsDownloadCompleteWithVerificationIssue(
+ request.DownloadControllerIsDownloadCompleteWithVerificationIssue),
+
+ Request.PayloadOneofCase.DownloadControllerAwaitCompletion
+ => await InteropDownloadController.HandleAwaitCompletion(request.DownloadControllerAwaitCompletion).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DownloadControllerPause
+ => InteropDownloadController.HandlePause(request.DownloadControllerPause),
+
+ Request.PayloadOneofCase.DownloadControllerResume
+ => InteropDownloadController.HandleResume(request.DownloadControllerResume),
+
+ Request.PayloadOneofCase.DownloadControllerFree
+ => await InteropDownloadController.HandleFree(request.DownloadControllerFree).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientCreate
+ => InteropProtonPhotosClient.HandleCreate(request.DrivePhotosClientCreate, bindingsHandle),
+
+ Request.PayloadOneofCase.DrivePhotosClientCreateFromSession
+ => InteropProtonPhotosClient.HandleCreate(request.DrivePhotosClientCreateFromSession),
+
+ Request.PayloadOneofCase.DrivePhotosClientFree
+ => InteropProtonPhotosClient.HandleFree(request.DrivePhotosClientFree),
+
+ Request.PayloadOneofCase.DrivePhotosClientEnumerateThumbnails
+ => await InteropProtonPhotosClient.HandleEnumerateThumbnailsAsync(
+ request.DrivePhotosClientEnumerateThumbnails, bindingsHandle).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientEnumerateTimeline
+ => await InteropProtonPhotosClient.HandleEnumeratePhotosTimelineAsync(
+ request.DrivePhotosClientEnumerateTimeline, bindingsHandle).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientGetNode
+ => await InteropProtonPhotosClient.HandleGetNodeAsync(request.DrivePhotosClientGetNode).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientGetPhotoDownloader
+ => await InteropProtonPhotosClient.HandleGetPhotosDownloaderAsync(request.DrivePhotosClientGetPhotoDownloader).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientDownloadToStream
+ => InteropPhotosDownloader.HandleDownloadToStream(request.DrivePhotosClientDownloadToStream, bindingsHandle),
+
+ Request.PayloadOneofCase.DrivePhotosClientDownloadToFile
+ => InteropPhotosDownloader.HandleDownloadToFile(request.DrivePhotosClientDownloadToFile, bindingsHandle),
+
+ Request.PayloadOneofCase.DrivePhotosClientDownloaderFree
+ => InteropPhotosDownloader.HandleFree(request.DrivePhotosClientDownloaderFree),
+
+ Request.PayloadOneofCase.DrivePhotosClientGetPhotoUploader
+ => await InteropProtonPhotosClient.HandleGetFileUploaderAsync(request.DrivePhotosClientGetPhotoUploader).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientFindDuplicates
+ => await InteropProtonPhotosClient.HandleFindDuplicatesAsync(request.DrivePhotosClientFindDuplicates, bindingsHandle).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientUploadFromStream
+ => InteropPhotosUploader.HandleUploadFromStream(request.DrivePhotosClientUploadFromStream, bindingsHandle),
+
+ Request.PayloadOneofCase.DrivePhotosClientUploadFromFile
+ => InteropPhotosUploader.HandleUploadFromFile(request.DrivePhotosClientUploadFromFile, bindingsHandle),
+
+ Request.PayloadOneofCase.DrivePhotosClientUploaderFree
+ => InteropPhotosUploader.HandleFree(request.DrivePhotosClientUploaderFree),
+
+ Request.PayloadOneofCase.DrivePhotosClientTrashNodes
+ => await InteropProtonPhotosClient.HandleTrashNodesAsync(request.DrivePhotosClientTrashNodes).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientDeleteNodes
+ => await InteropProtonPhotosClient.HandleDeleteNodesAsync(request.DrivePhotosClientDeleteNodes).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientRestoreNodes
+ => await InteropProtonPhotosClient.HandleRestoreNodesAsync(request.DrivePhotosClientRestoreNodes).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientEnumerateTrash
+ => await InteropProtonPhotosClient.HandleEnumerateTrashAsync(request.DrivePhotosClientEnumerateTrash, bindingsHandle).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.DrivePhotosClientEmptyTrash
+ => await InteropProtonPhotosClient.HandleEmptyTrashAsync(request.DrivePhotosClientEmptyTrash).ConfigureAwait(false),
+
+ Request.PayloadOneofCase.None or _
+ => throw new ArgumentException($"Unknown request type: {request.PayloadCase}", nameof(requestBytes)),
+ };
+
+ responseAction.InvokeWithMessage(bindingsHandle, response is not null ? new Response { Value = Any.Pack(response) } : new Response());
+ }
+ catch (Exception e)
+ {
+ var error = e.ToProtoError(InteropDriveErrorConverter.SetDomainAndCodes);
+
+ responseAction.InvokeWithMessage(bindingsHandle, new Response { Error = error });
+ }
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs
new file mode 100644
index 00000000..014a95e2
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosDownloader.cs
@@ -0,0 +1,59 @@
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes.Download;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropPhotosDownloader
+{
+ public static IMessage HandleDownloadToStream(DrivePhotosClientDownloadToStreamRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var downloader = Interop.GetFromHandle(request.DownloaderHandle);
+
+ var writeFunction = new InteropFunction, nint, nint>(request.WriteAction);
+
+ var seekAction = request.SeekAction != 0
+ ? new InteropAction, nint>(request.SeekAction)
+ : (InteropAction, nint>?)null;
+
+ var cancelAction = request.CancelAction != 0 ? new InteropAction(request.CancelAction) : (InteropAction?)null;
+ var stream = new InteropStream(bindingsHandle, writeFunction, seekAction, cancelAction);
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+
+ var downloadController = downloader.DownloadToStream(
+ stream,
+ (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal),
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(downloadController) };
+ }
+
+ public static IMessage HandleDownloadToFile(DrivePhotosClientDownloadToFileRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var downloader = Interop.GetFromHandle(request.DownloaderHandle);
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+
+ var downloadController = downloader.DownloadToFile(
+ request.FilePath,
+ (bytesCompleted, bytesInTotal) => progressAction.InvokeProgressUpdate(bindingsHandle, bytesCompleted, bytesInTotal),
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(downloadController) };
+ }
+
+ public static IMessage? HandleFree(DrivePhotosClientDownloaderFreeRequest request)
+ {
+ var fileDownloader = Interop.FreeHandle(request.FileDownloaderHandle);
+
+ fileDownloader.Dispose();
+
+ return null;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs
new file mode 100644
index 00000000..1a62f55a
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropPhotosUploader.cs
@@ -0,0 +1,81 @@
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes.Upload;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropPhotosUploader
+{
+ public static IMessage HandleUploadFromStream(DrivePhotosClientUploadFromStreamRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var uploader = Interop.GetFromHandle(request.UploaderHandle);
+
+ var stream = new InteropStream(uploader.FileSize, bindingsHandle, new InteropFunction, nint, nint>(request.ReadAction));
+
+ var thumbnails = request.Thumbnails.Select(t =>
+ {
+ unsafe
+ {
+ var thumbnailType = (Nodes.ThumbnailType)t.Type;
+ return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray());
+ }
+ });
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+
+ var expectedSha1Provider = request.HasSha1Function ?
+ InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null;
+
+ var uploadController = uploader.UploadFromStream(
+ stream,
+ thumbnails,
+ (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total),
+ expectedSha1Provider,
+ forPhotos: true,
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(uploadController) };
+ }
+
+ public static IMessage HandleUploadFromFile(DrivePhotosClientUploadFromFileRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var thumbnails = request.Thumbnails.Select(t =>
+ {
+ unsafe
+ {
+ var thumbnailType = (Nodes.ThumbnailType)t.Type;
+ return new Nodes.Thumbnail(thumbnailType, new InteropArray((byte*)t.DataPointer, (nint)t.DataLength).ToArray());
+ }
+ });
+
+ var progressAction = new InteropAction>(request.ProgressAction);
+ var expectedSha1Provider = request.HasSha1Function ?
+ InteropFileUploader.CreateSha1Provider(bindingsHandle, request.Sha1Function) : null;
+
+ var uploader = Interop.GetFromHandle(request.UploaderHandle);
+
+ var uploadController = uploader.UploadFromFile(
+ request.FilePath,
+ thumbnails,
+ (progress, total) => progressAction.InvokeProgressUpdate(bindingsHandle, progress, total),
+ expectedSha1Provider,
+ forPhotos: true,
+ cancellationToken);
+
+ return new Int64Value { Value = Interop.AllocHandle(uploadController) };
+ }
+
+ public static IMessage? HandleFree(DrivePhotosClientUploaderFreeRequest request)
+ {
+ var fileUploader = Interop.FreeHandle(request.FileUploaderHandle);
+
+ fileUploader.Dispose();
+
+ return null;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs
new file mode 100644
index 00000000..3e3d90c0
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonDriveClient.cs
@@ -0,0 +1,373 @@
+using System.Text.Json;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes;
+using Proton.Drive.Sdk.Nodes.Download;
+using Proton.Drive.Sdk.Nodes.Upload;
+using Proton.Sdk;
+using Proton.Sdk.Caching;
+using Proton.Sdk.CExports;
+using Proton.Sdk.Telemetry;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropProtonDriveClient
+{
+ public static IMessage HandleCreate(DriveClientCreateRequest request, nint bindingsHandle)
+ {
+ if (!request.BaseUrl.EndsWith('/'))
+ {
+ throw new UriFormatException("Base URL must end with a '/'");
+ }
+
+ var protonDriveClientOptions = new Sdk.ProtonDriveClientOptions(
+ request.ClientOptions.HasUid ? request.ClientOptions.Uid : null,
+ request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null,
+ request.ClientOptions.HasApiCallTimeout ? request.ClientOptions.ApiCallTimeout : null,
+ request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null,
+ request.ClientOptions.HasBlockTransferParallelism ? request.ClientOptions.BlockTransferParallelism : null);
+
+ var httpClientFactory = new InteropHttpClientFactory(
+ bindingsHandle,
+ request.BaseUrl,
+ protonDriveClientOptions.BindingsLanguage,
+ new InteropFunction, nint, nint>(request.HttpClient.RequestFunction),
+ new InteropFunction, nint, nint>(request.HttpClient.ResponseContentReadAction),
+ new InteropAction(request.HttpClient.CancellationAction));
+
+ var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction));
+
+ ICacheRepository entityCacheRepository = request.HasEntityCachePath
+ ? SqliteCacheRepository.OpenFile(request.EntityCachePath)
+ : new InMemoryCacheRepository();
+
+ ICacheRepository secretCacheRepository = request.HasSecretCachePath
+ ? SqliteCacheRepository.OpenFile(request.SecretCachePath)
+ : new InMemoryCacheRepository();
+
+ if (request.HasSecretCacheEncryptionKey)
+ {
+ secretCacheRepository = new EncryptedCacheRepository(
+ secretCacheRepository,
+ request.SecretCacheEncryptionKey.ToByteArray());
+ }
+
+ ITelemetry telemetry = request.Telemetry.ToTelemetry(bindingsHandle) is { } interopTelemetry
+ ? new DriveInteropTelemetryDecorator(interopTelemetry)
+ : NullTelemetry.Instance;
+
+ var featureFlagProvider = request.HasFeatureEnabledFunction
+ ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction))
+ : AlwaysDisabledFeatureFlagProvider.Instance;
+
+ var client = new ProtonDriveClient(
+ httpClientFactory,
+ accountClient,
+ entityCacheRepository,
+ secretCacheRepository,
+ featureFlagProvider,
+ telemetry,
+ protonDriveClientOptions);
+
+ return new Int64Value
+ {
+ Value = Interop.AllocHandle(client),
+ };
+ }
+
+ public static IMessage HandleCreate(DriveClientCreateFromSessionRequest request)
+ {
+ var session = Interop.GetFromHandle(request.SessionHandle);
+
+ var client = new ProtonDriveClient(session);
+
+ return new Int64Value { Value = Interop.AllocHandle(client) };
+ }
+
+ public static async ValueTask HandleCreateFolderAsync(DriveClientCreateFolderRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var createdFolder = await client.CreateFolderAsync(
+ NodeUid.Parse(request.ParentFolderUid),
+ request.FolderName,
+ request.LastModificationTime?.ToDateTimeFixed(),
+ cancellationToken).ConfigureAwait(false);
+
+ return createdFolder.ToInterop();
+ }
+
+ public static async ValueTask HandleGetFileUploaderAsync(DriveClientGetFileUploaderRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+ var additionalMetadata = request.AdditionalMetadata is { Count: > 0 }
+ ? request.AdditionalMetadata.Select(x =>
+ new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement))
+ : null;
+
+ var metadata = new FileUploadMetadata
+ {
+ LastModificationTime = request.LastModificationTime?.ToDateTimeFixed(),
+ AdditionalMetadata = additionalMetadata,
+ };
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ FileUploader? fileUploader;
+ if (request is { HasNoWaiting: true, NoWaiting: true })
+ {
+#pragma warning disable TryTransferQueuing
+ fileUploader = client.TryGetFileUploader(
+ NodeUid.Parse(request.ParentFolderUid),
+ request.Name,
+ request.MediaType,
+ request.Size,
+ metadata,
+ request.OverrideExistingDraftByOtherClient);
+#pragma warning restore TryTransferQueuing
+ }
+ else
+ {
+ fileUploader = await client.GetFileUploaderAsync(
+ NodeUid.Parse(request.ParentFolderUid),
+ request.Name,
+ request.MediaType,
+ request.Size,
+ metadata,
+ request.OverrideExistingDraftByOtherClient,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ return new Int64Value { Value = fileUploader is null ? 0 : Interop.AllocHandle(fileUploader) };
+ }
+
+ public static async ValueTask HandleGetFileRevisionUploaderAsync(DriveClientGetFileRevisionUploaderRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var additionalMetadata = request.AdditionalMetadata.Count > 0
+ ? request.AdditionalMetadata.Select(x =>
+ new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement))
+ : null;
+
+ var metadata = new FileUploadMetadata
+ {
+ LastModificationTime = request.LastModificationTime?.ToDateTimeFixed(),
+ AdditionalMetadata = additionalMetadata,
+ };
+
+ FileUploader? fileUploader;
+ if (request is { HasNoWaiting: true, NoWaiting: true })
+ {
+#pragma warning disable TryTransferQueuing
+ fileUploader = client.TryGetFileRevisionUploader(
+ RevisionUid.Parse(request.CurrentActiveRevisionUid),
+ request.Size,
+ metadata);
+#pragma warning restore TryTransferQueuing
+ }
+ else
+ {
+ fileUploader = await client.GetFileRevisionUploaderAsync(
+ RevisionUid.Parse(request.CurrentActiveRevisionUid),
+ request.Size,
+ metadata,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ return new Int64Value { Value = fileUploader is null ? 0 : Interop.AllocHandle(fileUploader) };
+ }
+
+ public static async ValueTask HandleGetAvailableNameAsync(DriveClientGetAvailableNameRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var availableName = await client.GetAvailableNameAsync(
+ NodeUid.Parse(request.ParentFolderUid),
+ request.Name,
+ cancellationToken).ConfigureAwait(false);
+
+ return new StringValue { Value = availableName };
+ }
+
+ public static async ValueTask HandleEnumerateThumbnailsAsync(DriveClientEnumerateThumbnailsRequest request, nint bindingsHandle)
+ {
+ var yieldFunction = new InteropAction>(request.YieldAction);
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var thumbnailsEnumerable = client.EnumerateThumbnailsAsync(
+ request.FileUids.Select(NodeUid.Parse),
+ (Nodes.ThumbnailType)request.Type,
+ cancellationToken);
+
+ await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false))
+ {
+ var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() };
+
+ if (x.Result.TryGetValueElseError(out var data, out var error))
+ {
+ thumbnail.Data = ByteString.CopyFrom(data.Span);
+ }
+ else
+ {
+ thumbnail.Error = error.ToInterop();
+ }
+
+ yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail);
+ }
+
+ return null;
+ }
+
+ public static async ValueTask HandleEnumerateFolderChildrenAsync(DriveClientEnumerateFolderChildrenRequest request, nint bindingsHandle)
+ {
+ var yieldFunction = new InteropAction>(request.YieldAction);
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ await foreach (var x in client.EnumerateFolderChildrenAsync(NodeUid.Parse(request.FolderUid), cancellationToken).ConfigureAwait(false))
+ {
+ yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop());
+ }
+
+ return null;
+ }
+
+ public static async ValueTask HandleGetMyFilesFolderAsync(DriveClientGetMyFilesFolderRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var folderNode = await client.GetMyFilesFolderAsync(cancellationToken).ConfigureAwait(false);
+
+ return folderNode.ToInterop();
+ }
+
+ public static async ValueTask HandleGetFileDownloaderAsync(DriveClientGetFileDownloaderRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+ var client = Interop.GetFromHandle(request.ClientHandle);
+ var revisionUid = RevisionUid.Parse(request.RevisionUid);
+
+ FileDownloader? fileDownloader;
+ if (request is { HasNoWaiting: true, NoWaiting: true })
+ {
+#pragma warning disable TryTransferQueuing
+ fileDownloader = client.TryGetFileDownloader(revisionUid);
+#pragma warning restore TryTransferQueuing
+ }
+ else
+ {
+ fileDownloader = await client.GetFileDownloaderAsync(revisionUid, cancellationToken).ConfigureAwait(false);
+ }
+
+ return new Int64Value { Value = fileDownloader is null ? 0 : Interop.AllocHandle(fileDownloader) };
+ }
+
+ public static async ValueTask HandleRenameAsync(DriveClientRenameRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ await client.RenameNodeAsync(
+ NodeUid.Parse(request.NodeUid),
+ request.NewName,
+ request.NewMediaType,
+ cancellationToken).ConfigureAwait(false);
+ return null;
+ }
+
+ public static async ValueTask HandleTrashNodesAsync(DriveClientTrashNodesRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var results = await client.TrashNodesAsync(
+ request.NodeUids.Select(NodeUid.Parse),
+ cancellationToken).ConfigureAwait(false);
+
+ return results.ToInterop();
+ }
+
+ public static async ValueTask HandleDeleteNodesAsync(DriveClientDeleteNodesRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var results = await client.DeleteNodesAsync(
+ request.NodeUids.Select(NodeUid.Parse),
+ cancellationToken).ConfigureAwait(false);
+
+ return results.ToInterop();
+ }
+
+ public static async ValueTask HandleRestoreNodesAsync(DriveClientRestoreNodesRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var results = await client.RestoreNodesAsync(
+ request.NodeUids.Select(NodeUid.Parse),
+ cancellationToken).ConfigureAwait(false);
+
+ return results.ToInterop();
+ }
+
+ public static async ValueTask HandleEnumerateTrashAsync(DriveClientEnumerateTrashRequest request, nint bindingsHandle)
+ {
+ var yieldFunction = new InteropAction>(request.YieldAction);
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false))
+ {
+ yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop());
+ }
+
+ return null;
+ }
+
+ public static async ValueTask HandleGetNodeAsync(DriveClientGetNodeRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var node = await client.GetNodeAsync(
+ NodeUid.Parse(request.NodeUid),
+ cancellationToken).ConfigureAwait(false);
+
+ return node?.ToInterop();
+ }
+
+ public static async ValueTask HandleEmptyTrashAsync(DriveClientEmptyTrashRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ await client.EmptyTrashAsync(cancellationToken).ConfigureAwait(false);
+
+ return null;
+ }
+
+ public static IMessage? HandleFree(DriveClientFreeRequest request)
+ {
+ Interop.FreeHandle(request.ClientHandle);
+
+ return null;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs
new file mode 100644
index 00000000..e58ed7a9
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropProtonPhotosClient.cs
@@ -0,0 +1,302 @@
+using System.Text.Json;
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes;
+using Proton.Drive.Sdk.Nodes.Download;
+using Proton.Drive.Sdk.Nodes.Upload;
+using Proton.Sdk;
+using Proton.Sdk.Caching;
+using Proton.Sdk.CExports;
+using Proton.Sdk.Telemetry;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropProtonPhotosClient
+{
+ public static IMessage HandleCreate(DrivePhotosClientCreateRequest request, nint bindingsHandle)
+ {
+ if (!request.BaseUrl.EndsWith('/'))
+ {
+ throw new UriFormatException("Base URL must end with a '/'");
+ }
+
+ var protonDriveClientOptions = new Sdk.ProtonDriveClientOptions(
+ request.ClientOptions.HasUid ? request.ClientOptions.Uid : null,
+ request.ClientOptions.HasBindingsLanguage ? request.ClientOptions.BindingsLanguage : null,
+ request.ClientOptions.HasApiCallTimeout ? request.ClientOptions.ApiCallTimeout : null,
+ request.ClientOptions.HasStorageCallTimeout ? request.ClientOptions.StorageCallTimeout : null,
+ request.ClientOptions.HasBlockTransferParallelism ? request.ClientOptions.BlockTransferParallelism : null);
+
+ var httpClientFactory = new InteropHttpClientFactory(
+ bindingsHandle,
+ request.BaseUrl,
+ protonDriveClientOptions.BindingsLanguage,
+ new InteropFunction, nint, nint>(request.HttpClient.RequestFunction),
+ new InteropFunction, nint, nint>(request.HttpClient.ResponseContentReadAction),
+ new InteropAction(request.HttpClient.CancellationAction));
+
+ var accountClient = new InteropAccountClient(bindingsHandle, new InteropAction, nint>(request.AccountRequestAction));
+
+ var entityCacheRepository = request.HasEntityCachePath
+ ? SqliteCacheRepository.OpenFile(request.EntityCachePath)
+ : SqliteCacheRepository.OpenInMemory();
+
+ var secretCacheRepository = request.HasSecretCachePath
+ ? SqliteCacheRepository.OpenFile(request.SecretCachePath)
+ : SqliteCacheRepository.OpenInMemory();
+
+ ITelemetry telemetry = request.Telemetry.ToTelemetry(bindingsHandle) is { } interopTelemetry
+ ? new DriveInteropTelemetryDecorator(interopTelemetry)
+ : NullTelemetry.Instance;
+
+ var featureFlagProvider = request.HasFeatureEnabledFunction
+ ? new InteropFeatureFlagProvider(bindingsHandle, new InteropFunction, int>(request.FeatureEnabledFunction))
+ : AlwaysDisabledFeatureFlagProvider.Instance;
+
+ var client = new ProtonPhotosClient(
+ httpClientFactory,
+ accountClient,
+ entityCacheRepository,
+ secretCacheRepository,
+ featureFlagProvider,
+ telemetry,
+ protonDriveClientOptions);
+
+ return new Int64Value
+ {
+ Value = Interop.AllocHandle(client),
+ };
+ }
+
+ public static IMessage HandleCreate(DrivePhotosClientCreateFromSessionRequest request)
+ {
+ var session = Interop.GetFromHandle(request.SessionHandle);
+
+ var client = new ProtonPhotosClient(session, request.Uid);
+
+ return new Int64Value { Value = Interop.AllocHandle(client) };
+ }
+
+ public static async ValueTask HandleTrashNodesAsync(DrivePhotosClientTrashNodesRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var results = await client.TrashNodesAsync(
+ request.NodeUids.Select(NodeUid.Parse),
+ cancellationToken).ConfigureAwait(false);
+
+ return results.ToInterop();
+ }
+
+ public static async ValueTask HandleDeleteNodesAsync(DrivePhotosClientDeleteNodesRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var results = await client.DeleteNodesAsync(
+ request.NodeUids.Select(NodeUid.Parse),
+ cancellationToken).ConfigureAwait(false);
+
+ return results.ToInterop();
+ }
+
+ public static async ValueTask HandleRestoreNodesAsync(DrivePhotosClientRestoreNodesRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var results = await client.RestoreNodesAsync(
+ request.NodeUids.Select(NodeUid.Parse),
+ cancellationToken).ConfigureAwait(false);
+
+ return results.ToInterop();
+ }
+
+ public static async ValueTask HandleEnumerateTrashAsync(DrivePhotosClientEnumerateTrashRequest request, nint bindingsHandle)
+ {
+ var yieldFunction = new InteropAction>(request.YieldAction);
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ await foreach (var x in client.EnumerateTrashAsync(cancellationToken).ConfigureAwait(false))
+ {
+ yieldFunction.InvokeWithMessage(bindingsHandle, x.ToInterop());
+ }
+
+ return null;
+ }
+
+ public static async ValueTask HandleEmptyTrashAsync(DrivePhotosClientEmptyTrashRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ await client.EmptyTrashAsync(cancellationToken).ConfigureAwait(false);
+
+ return null;
+ }
+
+ public static IMessage? HandleFree(DrivePhotosClientFreeRequest request)
+ {
+ Interop.FreeHandle(request.ClientHandle);
+
+ return null;
+ }
+
+ public static async ValueTask HandleGetNodeAsync(DrivePhotosClientGetNodeRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var node = await client.GetNodeAsync(NodeUid.Parse(request.NodeUid), cancellationToken).ConfigureAwait(false);
+
+ return node?.ToInterop();
+ }
+
+ public static async ValueTask HandleEnumeratePhotosTimelineAsync(DrivePhotosClientEnumerateTimelineRequest request, nint bindingsHandle)
+ {
+ var yieldFunction = new InteropAction>(request.YieldAction);
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ await foreach (var x in client.EnumerateTimelineAsync(cancellationToken).ConfigureAwait(false))
+ {
+ yieldFunction.InvokeWithMessage(bindingsHandle, new PhotosTimelineItem
+ {
+ NodeUid = x.Uid.ToString(),
+ CaptureTime = x.CaptureTime.ToUniversalTime().ToTimestamp(),
+ });
+ }
+
+ return null;
+ }
+
+ public static async ValueTask HandleGetPhotosDownloaderAsync(DrivePhotosClientGetPhotoDownloaderRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var photoUid = NodeUid.Parse(request.PhotoUid);
+
+ PhotosFileDownloader? downloader;
+ if (request is { HasNoWaiting: true, NoWaiting: true })
+ {
+#pragma warning disable TryTransferQueuing
+ downloader = client.TryGetPhotosDownloader(photoUid);
+#pragma warning restore TryTransferQueuing
+ }
+ else
+ {
+ downloader = await client.GetPhotosDownloaderAsync(photoUid, cancellationToken).ConfigureAwait(false);
+ }
+
+ return new Int64Value { Value = downloader is null ? 0 : Interop.AllocHandle(downloader) };
+ }
+
+ public static async ValueTask HandleEnumerateThumbnailsAsync(DrivePhotosClientEnumerateThumbnailsRequest request, nint bindingsHandle)
+ {
+ var yieldFunction = new InteropAction>(request.YieldAction);
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var thumbnailsEnumerable = client.EnumerateThumbnailsAsync(
+ request.PhotoUids.Select(NodeUid.Parse),
+ (Nodes.ThumbnailType)request.Type,
+ cancellationToken);
+
+ await foreach (var x in thumbnailsEnumerable.ConfigureAwait(false))
+ {
+ var thumbnail = new FileThumbnail { FileUid = x.FileUid.ToString() };
+ if (x.Result.TryGetValueElseError(out var data, out var error))
+ {
+ thumbnail.Data = ByteString.CopyFrom(data.Span);
+ }
+ else
+ {
+ thumbnail.Error = error.ToInterop();
+ }
+
+ yieldFunction.InvokeWithMessage(bindingsHandle, thumbnail);
+ }
+
+ return null;
+ }
+
+ public static async ValueTask HandleGetFileUploaderAsync(DrivePhotosClientGetPhotoUploaderRequest request)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var tags = request.Metadata.Tags is { Count: > 0 }
+ ? request.Metadata.Tags.Select(t => (Nodes.PhotoTag)t)
+ : null;
+
+ var additionalMetadata = request.Metadata.AdditionalMetadata is { Count: > 0 }
+ ? request.Metadata.AdditionalMetadata.Select(x =>
+ new Nodes.AdditionalMetadataProperty(x.Name, JsonDocument.Parse(x.Utf8JsonValue.Memory).RootElement))
+ : null;
+
+ var metadata = new Nodes.PhotosFileUploadMetadata
+ {
+ AdditionalMetadata = additionalMetadata,
+ LastModificationTime = request.Metadata.LastModificationTime?.ToDateTimeFixed(),
+ CaptureTime = request.Metadata.CaptureTime?.ToDateTimeFixed(),
+ MainPhotoUid = request.Metadata.HasMainPhotoUid ? NodeUid.Parse(request.Metadata.MainPhotoUid) : null,
+ Tags = tags,
+ };
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ FileUploader? uploader;
+ if (request is { HasNoWaiting: true, NoWaiting: true })
+ {
+#pragma warning disable TryTransferQueuing
+ uploader = await client.TryGetFileUploaderAsync(
+ request.Name,
+ request.MediaType,
+ request.Size,
+ metadata,
+ request.OverrideExistingDraftByOtherClient,
+ cancellationToken).ConfigureAwait(false);
+#pragma warning restore TryTransferQueuing
+ }
+ else
+ {
+ uploader = await client.GetFileUploaderAsync(
+ request.Name,
+ request.MediaType,
+ request.Size,
+ metadata,
+ request.OverrideExistingDraftByOtherClient,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ return new Int64Value { Value = uploader is null ? 0 : Interop.AllocHandle(uploader) };
+ }
+
+ public static async ValueTask HandleFindDuplicatesAsync(DrivePhotosClientFindDuplicatesRequest request, nint bindingsHandle)
+ {
+ var cancellationToken = Interop.GetCancellationToken(request.CancellationTokenSourceHandle);
+
+ var client = Interop.GetFromHandle(request.ClientHandle);
+
+ var duplicates = await client.FindDuplicatesAsync(request.Name, GenerateSha1Action, cancellationToken).ConfigureAwait(false);
+
+ var result = new ListValue();
+ result.Values.AddRange(duplicates.Select(Value.ForString));
+
+ return result;
+
+ static void GenerateSha1Action(string sha1)
+ {
+ // TODO: Implement SHA1 generation callback
+ }
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs
new file mode 100644
index 00000000..5b505c3c
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/InteropUploadController.cs
@@ -0,0 +1,59 @@
+using Google.Protobuf;
+using Google.Protobuf.WellKnownTypes;
+using Proton.Drive.Sdk.Nodes.Upload;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class InteropUploadController
+{
+ public static IMessage HandleIsPaused(UploadControllerIsPausedRequest request)
+ {
+ var uploadController = Interop.GetFromHandle(request.UploadControllerHandle);
+
+ return new BoolValue { Value = uploadController.IsPaused };
+ }
+
+ public static async ValueTask HandleAwaitCompletion(UploadControllerAwaitCompletionRequest request)
+ {
+ var uploadController = Interop.GetFromHandle(request.UploadControllerHandle);
+
+ var uploadResult = await uploadController.Completion.ConfigureAwait(false);
+
+ return new UploadResult { NodeUid = uploadResult.NodeUid.ToString(), RevisionUid = uploadResult.RevisionUid.ToString() };
+ }
+
+ public static IMessage? HandlePause(UploadControllerPauseRequest request)
+ {
+ var uploadController = Interop.GetFromHandle(request.UploadControllerHandle);
+
+ uploadController.Pause();
+
+ return null;
+ }
+
+ public static IMessage? HandleResume(UploadControllerResumeRequest request)
+ {
+ var uploadController = Interop.GetFromHandle(request.UploadControllerHandle);
+
+ uploadController.Resume();
+
+ return null;
+ }
+
+ public static async ValueTask HandleDisposeAsync(UploadControllerDisposeRequest request)
+ {
+ var uploadController = Interop.GetFromHandle(request.UploadControllerHandle);
+
+ await uploadController.DisposeAsync().ConfigureAwait(false);
+
+ return null;
+ }
+
+ public static IMessage? HandleFree(UploadControllerFreeRequest request)
+ {
+ Interop.FreeHandle(request.UploadControllerHandle);
+
+ return null;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs
new file mode 100644
index 00000000..1503485b
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/NativeLibraryResolver.cs
@@ -0,0 +1,35 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Runtime.Loader;
+using System.Text;
+using Proton.Sdk.CExports;
+
+namespace Proton.Drive.Sdk.CExports;
+
+public static class NativeLibraryResolver
+{
+ private static readonly ConcurrentDictionary LibraryNameMap = new();
+
+ [UnmanagedCallersOnly(EntryPoint = "override_native_library_name", CallConvs = [typeof(CallConvCdecl)])]
+ private static void OverrideNativeLibraryName(InteropArray libraryNameBytes, InteropArray overridingLibraryNameBytes)
+ {
+ var libraryName = Encoding.UTF8.GetString(libraryNameBytes.AsReadOnlySpan());
+
+ LibraryNameMap[libraryName] = Encoding.UTF8.GetString(overridingLibraryNameBytes.AsReadOnlySpan());
+
+ AssemblyLoadContext.Default.ResolvingUnmanagedDll -= Resolve;
+ AssemblyLoadContext.Default.ResolvingUnmanagedDll += Resolve;
+ }
+
+ private static nint Resolve(Assembly assembly, string libraryName)
+ {
+ if (LibraryNameMap.TryGetValue(libraryName, out var overridingLibraryName))
+ {
+ libraryName = overridingLibraryName;
+ }
+
+ return NativeLibrary.Load(libraryName, assembly, null);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj
new file mode 100644
index 00000000..7c293d6c
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/Proton.Drive.Sdk.CExports.csproj
@@ -0,0 +1,62 @@
+
+
+
+ $(NativeLibPrefix)proton_drive_sdk
+ true
+ true
+ false
+ proton_crypto
+
+ $(NoWarn);Photos
+
+
+ $(NoWarn);IL2113
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+ Static
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cs/sdk/src/Proton.Drive.Sdk.CExports/TimestampExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk.CExports/TimestampExtensions.cs
new file mode 100644
index 00000000..0563f7d6
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk.CExports/TimestampExtensions.cs
@@ -0,0 +1,19 @@
+using Google.Protobuf.WellKnownTypes;
+
+namespace Proton.Drive.Sdk.CExports;
+
+internal static class TimestampExtensions
+{
+ // Workaround for issue: http://github.com/protocolbuffers/protobuf/issues/26006
+ public static DateTime ToDateTimeFixed(this Timestamp timestamp)
+ {
+ try
+ {
+ return timestamp.ToDateTime();
+ }
+ catch (InvalidOperationException e)
+ {
+ throw new InvalidOperationException($"Timestamp contains invalid values: Seconds={timestamp.Seconds}; Nanos={timestamp.Nanos}", e);
+ }
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs
new file mode 100644
index 00000000..f624df44
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/AccountClientAdapter.cs
@@ -0,0 +1,35 @@
+using Proton.Cryptography.Pgp;
+using Proton.Sdk;
+using Proton.Sdk.Addresses;
+
+namespace Proton.Drive.Sdk;
+
+internal sealed class AccountClientAdapter(ProtonApiSession session) : IAccountClient
+{
+ private readonly ProtonAccountClient _client = new(session);
+
+ public ValueTask GetAddressAsync(AddressId addressId, CancellationToken cancellationToken)
+ {
+ return _client.GetAddressAsync(addressId, cancellationToken);
+ }
+
+ public ValueTask GetDefaultAddressAsync(CancellationToken cancellationToken)
+ {
+ return _client.GetCurrentUserDefaultAddressAsync(cancellationToken);
+ }
+
+ public ValueTask GetAddressPrimaryPrivateKeyAsync(AddressId addressId, CancellationToken cancellationToken)
+ {
+ return _client.GetAddressPrimaryPrivateKeyAsync(addressId, cancellationToken);
+ }
+
+ public ValueTask> GetAddressPrivateKeysAsync(AddressId addressId, CancellationToken cancellationToken)
+ {
+ return _client.GetAddressPrivateKeysAsync(addressId, cancellationToken);
+ }
+
+ public ValueTask> GetAddressPublicKeysAsync(string emailAddress, CancellationToken cancellationToken)
+ {
+ return _client.GetAddressPublicKeysAsync(emailAddress, cancellationToken);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/AlternateFileNameGenerator.cs b/cs/sdk/src/Proton.Drive.Sdk/AlternateFileNameGenerator.cs
new file mode 100644
index 00000000..7af31c0a
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/AlternateFileNameGenerator.cs
@@ -0,0 +1,12 @@
+namespace Proton.Drive.Sdk;
+
+internal static class AlternateFileNameGenerator
+{
+ public static IEnumerable GetNames(string originalName)
+ {
+ var nameWithoutExtension = Path.GetFileNameWithoutExtension(originalName);
+ var extension = originalName[nameWithoutExtension.Length..];
+
+ return Enumerable.Range(1, int.MaxValue).Select(i => $"{nameWithoutExtension} ({i}){extension}");
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.cs
new file mode 100644
index 00000000..372b2385
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/BlockVerificationApiClient.cs
@@ -0,0 +1,23 @@
+using Proton.Drive.Sdk.Api.Files;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Serialization;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Http;
+
+namespace Proton.Drive.Sdk.Api.BlockVerification;
+
+internal sealed class BlockVerificationApiClient(HttpClient httpClient) : IBlockVerificationApiClient
+{
+ private readonly HttpClient _httpClient = httpClient;
+
+ public async ValueTask GetVerificationInputAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionId revisionId,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.BlockVerificationInputResponse)
+ .GetAsync($"v2/volumes/{volumeId}/links/{linkId}/revisions/{revisionId}/verification", cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs
new file mode 100644
index 00000000..93939ecd
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/BlockVerification/IBlockVerificationApiClient.cs
@@ -0,0 +1,14 @@
+using Proton.Drive.Sdk.Api.Files;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Volumes;
+
+namespace Proton.Drive.Sdk.Api.BlockVerification;
+
+internal interface IBlockVerificationApiClient
+{
+ public ValueTask GetVerificationInputAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionId revisionId,
+ CancellationToken cancellationToken);
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs
new file mode 100644
index 00000000..55a21d17
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClients.cs
@@ -0,0 +1,21 @@
+using Proton.Drive.Sdk.Api.Files;
+using Proton.Drive.Sdk.Api.Folders;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Api.Photos;
+using Proton.Drive.Sdk.Api.Shares;
+using Proton.Drive.Sdk.Api.Storage;
+using Proton.Drive.Sdk.Api.Volumes;
+
+namespace Proton.Drive.Sdk.Api;
+
+internal sealed class DriveApiClients(HttpClient defaultHttpClient, HttpClient storageHttpClient) : IDriveApiClients
+{
+ public IVolumesApiClient Volumes { get; } = new VolumesApiClient(defaultHttpClient);
+ public ISharesApiClient Shares { get; } = new SharesApiClient(defaultHttpClient);
+ public ILinksApiClient Links { get; } = new LinksApiClient(defaultHttpClient);
+ public IFoldersApiClient Folders { get; } = new FoldersApiClient(defaultHttpClient);
+ public IFilesApiClient Files { get; } = new FilesApiClient(defaultHttpClient);
+ public IStorageApiClient Storage { get; } = new StorageApiClient(defaultHttpClient, storageHttpClient);
+ public ITrashApiClient Trash { get; } = new TrashApiClient(defaultHttpClient);
+ public IPhotosApiClient Photos { get; } = new PhotosApiClient(defaultHttpClient);
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClientsExtensions.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClientsExtensions.cs
new file mode 100644
index 00000000..e1bfb451
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/DriveApiClientsExtensions.cs
@@ -0,0 +1,17 @@
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Volumes;
+
+namespace Proton.Drive.Sdk.Api;
+
+internal static class DriveApiClientsExtensions
+{
+ public static ValueTask GetLinkDetailsAsync(
+ this IDriveApiClients api,
+ VolumeId volumeId,
+ IEnumerable linkIds,
+ bool forPhotos,
+ CancellationToken cancellationToken)
+ => forPhotos
+ ? api.Photos.GetDetailsAsync(volumeId, linkIds, cancellationToken)
+ : api.Links.GetDetailsAsync(volumeId, linkIds, cancellationToken);
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs
new file mode 100644
index 00000000..486640fd
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ActiveRevisionDto.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ActiveRevisionDto
+{
+ [JsonPropertyName("RevisionID")]
+ public required RevisionId Id { get; init; }
+
+ [JsonPropertyName("CreateTime")]
+ [JsonConverter(typeof(EpochSecondsJsonConverter))]
+ public required DateTime CreationTime { get; init; }
+
+ [JsonPropertyName("EncryptedSize")]
+ public required long StorageQuotaConsumption { get; init; }
+
+ public bool? ChecksumVerified { get; init; }
+
+ public PgpArmoredSignature? ManifestSignature { get; init; }
+
+ [JsonPropertyName("XAttr")]
+ public PgpArmoredMessage? ExtendedAttributes { get; init; }
+
+ public required IReadOnlyList Thumbnails { get; init; }
+
+ [JsonPropertyName("SignatureEmail")]
+ public string? SignatureEmailAddress { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs
new file mode 100644
index 00000000..060e18f1
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockCreationRequest.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class BlockCreationRequest
+{
+ public required int Index { get; init; }
+ public required int Size { get; init; }
+
+ [JsonPropertyName("EncSignature")]
+ public required PgpArmoredMessage EncryptedSignature { get; init; }
+
+ [JsonPropertyName("Hash")]
+ public required ReadOnlyMemory HashDigest { get; init; }
+
+ [JsonPropertyName("Verifier")]
+ public required BlockVerificationOutput VerificationOutput { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs
new file mode 100644
index 00000000..e7667853
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockDto.cs
@@ -0,0 +1,18 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class BlockDto
+{
+ public required int Index { get; init; }
+
+ [JsonPropertyName("Hash")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public required ReadOnlyMemory HashDigest { get; init; }
+
+ [JsonPropertyName("BareURL")]
+ public required string BareUrl { get; init; }
+
+ public required string Token { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs
new file mode 100644
index 00000000..e76fb66d
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockListingRevisionDto.cs
@@ -0,0 +1,6 @@
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class BlockListingRevisionDto : RevisionDto
+{
+ public required IReadOnlyList Blocks { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs
new file mode 100644
index 00000000..2e100508
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationRequest.cs
@@ -0,0 +1,27 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Addresses;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class BlockUploadPreparationRequest
+{
+ [JsonPropertyName("AddressID")]
+ public required AddressId AddressId { get; init; }
+
+ [JsonPropertyName("VolumeID")]
+ public required VolumeId VolumeId { get; init; }
+
+ [JsonPropertyName("LinkID")]
+ public required LinkId LinkId { get; init; }
+
+ [JsonPropertyName("RevisionID")]
+ public required RevisionId RevisionId { get; init; }
+
+ [JsonPropertyName("BlockList")]
+ public required IReadOnlyList Blocks { get; init; }
+
+ [JsonPropertyName("ThumbnailList")]
+ public required IReadOnlyList Thumbnails { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs
new file mode 100644
index 00000000..e13089e0
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadPreparationResponse.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class BlockUploadPreparationResponse : ApiResponse
+{
+ [JsonPropertyName("UploadLinks")]
+ public required IReadOnlyList UploadTargets { get; set; }
+
+ [JsonPropertyName("ThumbnailLinks")]
+ public required IReadOnlyList ThumbnailUploadTargets { get; set; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs
new file mode 100644
index 00000000..1cefe921
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadTarget.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal class BlockUploadTarget
+{
+ [JsonPropertyName("BareURL")]
+ public required string BareUrl { get; set; }
+
+ public required string Token { get; set; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs
new file mode 100644
index 00000000..1a1a3b7f
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockUploadUrl.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class BlockUploadUrl
+{
+ public required string Token { get; init; }
+
+ [JsonPropertyName("URL")]
+ public required string Value { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs
new file mode 100644
index 00000000..a9532c68
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationInputResponse.cs
@@ -0,0 +1,8 @@
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed record BlockVerificationInputResponse
+{
+ public required ReadOnlyMemory VerificationCode { get; init; }
+
+ public required ReadOnlyMemory ContentKeyPacket { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs
new file mode 100644
index 00000000..929e5037
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/BlockVerificationOutput.cs
@@ -0,0 +1,6 @@
+namespace Proton.Drive.Sdk.Api.Files;
+
+public readonly struct BlockVerificationOutput
+{
+ public required ReadOnlyMemory Token { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs
new file mode 100644
index 00000000..a3628e85
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/CommonExtendedAttributes.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Serialization;
+using Proton.Sdk;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class CommonExtendedAttributes
+{
+ public long? Size { get; init; }
+
+ [JsonConverter(typeof(Iso8601DateTimeResultJsonConverter))]
+ public Result? ModificationTime { get; init; }
+
+ public IReadOnlyList? BlockSizes { get; init; }
+
+ public FileContentDigestsDto? Digests { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs
new file mode 100644
index 00000000..08036fb4
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ExtendedAttributes.cs
@@ -0,0 +1,12 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal struct ExtendedAttributes
+{
+ public CommonExtendedAttributes? Common { get; init; }
+
+ [JsonExtensionData]
+ public Dictionary? AdditionalMetadata { get; set; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileContentDigestsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileContentDigestsDto.cs
new file mode 100644
index 00000000..a26523d4
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileContentDigestsDto.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal readonly struct FileContentDigestsDto
+{
+ [JsonPropertyName("SHA1")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public ReadOnlyMemory? Sha1 { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs
new file mode 100644
index 00000000..da3a9545
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationIdentifiers.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class FileCreationIdentifiers
+{
+ [JsonPropertyName("ID")]
+ public required LinkId LinkId { get; init; }
+
+ [JsonPropertyName("RevisionID")]
+ public required RevisionId RevisionId { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs
new file mode 100644
index 00000000..69700606
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationRequest.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Sdk.Cryptography;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class FileCreationRequest : NodeCreationRequest
+{
+ [JsonPropertyName("MIMEType")]
+ public required string MediaType { get; init; }
+
+ public required ReadOnlyMemory ContentKeyPacket { get; init; }
+
+ [JsonPropertyName("ContentKeyPacketSignature")]
+ public required PgpArmoredSignature ContentKeySignature { get; init; }
+
+ [JsonPropertyName("ClientUID")]
+ public string? ClientUid { get; init; }
+
+ public long? IntendedUploadSize { get; init; }
+
+ [JsonPropertyName("SignatureAddress")]
+ public required string SignatureEmailAddress { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs
new file mode 100644
index 00000000..f2349b45
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileCreationResponse.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class FileCreationResponse : ApiResponse
+{
+ [JsonPropertyName("File")]
+ public required FileCreationIdentifiers Identifiers { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs
new file mode 100644
index 00000000..e67178b5
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FileDto.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal class FileDto
+{
+ public required string MediaType { get; init; }
+
+ [JsonPropertyName("TotalEncryptedSize")]
+ public required long TotalSizeOnStorage { get; init; }
+
+ public required ReadOnlyMemory ContentKeyPacket { get; init; }
+
+ [JsonPropertyName("ContentKeyPacketSignature")]
+ public PgpArmoredSignature? ContentKeySignature { get; init; }
+
+ public ActiveRevisionDto? ActiveRevision { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs
new file mode 100644
index 00000000..7bf8e324
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/FilesApiClient.cs
@@ -0,0 +1,114 @@
+using System.Text;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Serialization;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Api;
+using Proton.Sdk.Http;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class FilesApiClient(HttpClient httpClient) : IFilesApiClient
+{
+ private readonly HttpClient _httpClient = httpClient;
+
+ public async ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.FileCreationResponse, DriveApiSerializerContext.Default.RevisionErrorResponse)
+ .PostAsync($"v2/volumes/{volumeId}/files", request, DriveApiSerializerContext.Default.FileCreationRequest, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask CreateRevisionAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionCreationRequest request,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.RevisionCreationResponse, DriveApiSerializerContext.Default.RevisionErrorResponse)
+ .PostAsync(
+ $"v2/volumes/{volumeId}/files/{linkId}/revisions",
+ request,
+ DriveApiSerializerContext.Default.RevisionCreationRequest,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.BlockUploadPreparationResponse)
+ .PostAsync("blocks", request, DriveApiSerializerContext.Default.BlockUploadPreparationRequest, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask UpdateRevisionAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionId revisionId,
+ RevisionUpdateRequest request,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(ProtonApiSerializerContext.Default.ApiResponse)
+ .PutAsync(
+ $"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}",
+ request,
+ DriveApiSerializerContext.Default.RevisionUpdateRequest,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask GetRevisionAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionId revisionId,
+ int? fromBlockIndex,
+ int? pageSize,
+ bool withoutBlockUrls,
+ CancellationToken cancellationToken)
+ {
+ var routeBuilder = new StringBuilder();
+
+ routeBuilder.Append($"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}?");
+
+ if (fromBlockIndex is not null)
+ {
+ routeBuilder.Append($"FromBlockIndex={fromBlockIndex}&");
+ }
+
+ if (pageSize is not null)
+ {
+ routeBuilder.Append($"PageSize={pageSize}&");
+ }
+
+ routeBuilder.Append($"NoBlockUrls={(withoutBlockUrls ? 1 : 0)}");
+
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.RevisionResponse)
+ .GetAsync(routeBuilder.ToString(), cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask DeleteRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionId revisionId, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.ApiResponse)
+ .DeleteAsync($"v2/volumes/{volumeId}/files/{linkId}/revisions/{revisionId}", cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask GetThumbnailBlocksAsync(
+ VolumeId volumeId,
+ IEnumerable thumbnailIds,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.ThumbnailBlockListResponse)
+ .PostAsync(
+ $"volumes/{volumeId}/thumbnails",
+ new ThumbnailBlockListRequest { ThumbnailIds = thumbnailIds },
+ DriveApiSerializerContext.Default.ThumbnailBlockListRequest,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs
new file mode 100644
index 00000000..f85f3b88
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/IFilesApiClient.cs
@@ -0,0 +1,38 @@
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal interface IFilesApiClient
+{
+ ValueTask CreateFileAsync(VolumeId volumeId, FileCreationRequest request, CancellationToken cancellationToken);
+
+ ValueTask CreateRevisionAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionCreationRequest request,
+ CancellationToken cancellationToken);
+
+ ValueTask PrepareBlockUploadAsync(BlockUploadPreparationRequest request, CancellationToken cancellationToken);
+
+ ValueTask UpdateRevisionAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionId revisionId,
+ RevisionUpdateRequest request,
+ CancellationToken cancellationToken);
+
+ ValueTask GetRevisionAsync(
+ VolumeId volumeId,
+ LinkId linkId,
+ RevisionId revisionId,
+ int? fromBlockIndex,
+ int? pageSize,
+ bool withoutBlockUrls,
+ CancellationToken cancellationToken);
+
+ ValueTask GetThumbnailBlocksAsync(VolumeId volumeId, IEnumerable thumbnailIds, CancellationToken cancellationToken);
+
+ ValueTask DeleteRevisionAsync(VolumeId volumeId, LinkId linkId, RevisionId revisionId, CancellationToken cancellationToken);
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/PhotosAttributesDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/PhotosAttributesDto.cs
new file mode 100644
index 00000000..37463368
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/PhotosAttributesDto.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Nodes;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class PhotosAttributesDto
+{
+ [JsonConverter(typeof(EpochSecondsJsonConverter))]
+ public required DateTime CaptureTime { get; init; }
+
+ [JsonPropertyName("ContentHash")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public required ReadOnlyMemory ContentHashDigest { get; init; }
+
+ [JsonPropertyName("MainPhotoLinkID")]
+ public LinkId? MainPhotoLinkId { get; init; }
+
+ public IReadOnlySet? Tags { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs
new file mode 100644
index 00000000..0d9ce361
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionConflict.cs
@@ -0,0 +1,29 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Serialization;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class RevisionConflict
+{
+ [JsonPropertyName("ConflictLinkID")]
+ public LinkId? LinkId { get; init; }
+
+ [JsonPropertyName("ConflictRevisionID")]
+ public RevisionId? RevisionId { get; init; }
+
+ [JsonPropertyName("ConflictDraftRevisionID")]
+ public RevisionId? DraftRevisionId { get; init; }
+
+ [JsonPropertyName("ConflictDraftClientUID")]
+ public string? DraftClientUid { get; init; }
+
+ public static RevisionConflict? FromErrorResponse(RevisionErrorResponse? errorResponse)
+ {
+ return errorResponse?.Code is ResponseCode.AlreadyExists
+ ? errorResponse.Details?.Deserialize(DriveApiSerializerContext.Default.RevisionConflict)
+ : null;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationIdentity.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationIdentity.cs
new file mode 100644
index 00000000..9825b8e4
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationIdentity.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal readonly struct RevisionCreationIdentity
+{
+ [JsonPropertyName("ID")]
+ public required RevisionId RevisionId { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs
new file mode 100644
index 00000000..41b4bf8c
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationRequest.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal struct RevisionCreationRequest
+{
+ [JsonPropertyName("CurrentRevisionID")]
+ public RevisionId? CurrentRevisionId { get; init; }
+
+ [JsonPropertyName("ClientUID")]
+ public string? ClientId { get; init; }
+
+ public long? IntendedUploadSize { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationResponse.cs
new file mode 100644
index 00000000..62604eb5
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionCreationResponse.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class RevisionCreationResponse : ApiResponse
+{
+ [JsonPropertyName("Revision")]
+ public required RevisionCreationIdentity Identity { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs
new file mode 100644
index 00000000..257c6161
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionDto.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Nodes;
+using Proton.Sdk.Cryptography;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal class RevisionDto
+{
+ [JsonPropertyName("ID")]
+ public required RevisionId Id { get; init; }
+
+ [JsonPropertyName("ClientUID")]
+ public string? ClientId { get; init; }
+
+ [JsonPropertyName("CreateTime")]
+ [JsonConverter(typeof(EpochSecondsJsonConverter))]
+ public required DateTime CreationTime { get; init; }
+
+ public required long Size { get; init; }
+
+ public PgpArmoredSignature? ManifestSignature { get; init; }
+
+ [JsonPropertyName("SignatureEmail")]
+ public string? SignatureEmailAddress { get; init; }
+
+ public required RevisionState State { get; init; }
+
+ [JsonPropertyName("XAttr")]
+ public PgpArmoredMessage? ExtendedAttributes { get; init; }
+
+ public IReadOnlyList? Thumbnails { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs
new file mode 100644
index 00000000..a97bb7b3
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionErrorResponse.cs
@@ -0,0 +1,9 @@
+using System.Text.Json;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class RevisionErrorResponse : ApiResponse
+{
+ public JsonElement? Details { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs
new file mode 100644
index 00000000..94b97259
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionId.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+[JsonConverter(typeof(StrongIdJsonConverter))]
+internal readonly record struct RevisionId : IStrongId
+{
+ private readonly string? _value;
+
+ internal RevisionId(string? value)
+ {
+ _value = value;
+ }
+
+ public static explicit operator RevisionId(string? value)
+ {
+ return new RevisionId(value);
+ }
+
+ public override string ToString()
+ {
+ return _value ?? string.Empty;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs
new file mode 100644
index 00000000..5367d4cc
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionResponse.cs
@@ -0,0 +1,8 @@
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class RevisionResponse : ApiResponse
+{
+ public required BlockListingRevisionDto Revision { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs
new file mode 100644
index 00000000..9191cb68
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/RevisionUpdateRequest.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class RevisionUpdateRequest
+{
+ public required PgpArmoredSignature ManifestSignature { get; init; }
+
+ [JsonPropertyName("SignatureAddress")]
+ public required string SignatureEmailAddress { get; init; }
+
+ public required bool ChecksumVerified { get; init; }
+
+ [JsonPropertyName("XAttr")]
+ public PgpArmoredMessage? ExtendedAttributes { get; init; }
+
+ [JsonPropertyName("Photo")]
+ public PhotosAttributesDto? PhotosAttributes { get; set; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs
new file mode 100644
index 00000000..7216959e
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlock.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ThumbnailBlock
+{
+ [JsonPropertyName("ThumbnailID")]
+ public required string ThumbnailId { get; init; }
+
+ [JsonPropertyName("BareURL")]
+ public required string BareUrl { get; init; }
+
+ public required string Token { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockError.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockError.cs
new file mode 100644
index 00000000..11ee8ab1
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockError.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ThumbnailBlockError
+{
+ [JsonPropertyName("ThumbnailID")]
+ public required string ThumbnailId { get; init; }
+
+ [JsonPropertyName("Error")]
+ public required string Error { get; init; }
+
+ [JsonPropertyName("Code")]
+ public required int Code { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListRequest.cs
new file mode 100644
index 00000000..ad5ef850
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListRequest.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal struct ThumbnailBlockListRequest
+{
+ [JsonPropertyName("ThumbnailIDs")]
+ public required IEnumerable ThumbnailIds { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs
new file mode 100644
index 00000000..4c0d5db7
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockListResponse.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ThumbnailBlockListResponse : ApiResponse
+{
+ [JsonPropertyName("Thumbnails")]
+ public required IReadOnlyList Blocks { get; init; }
+
+ [JsonPropertyName("Errors")]
+ public required IReadOnlyList Errors { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs
new file mode 100644
index 00000000..324cb0a3
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailBlockUploadTarget.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ThumbnailBlockUploadTarget : BlockUploadTarget
+{
+ [JsonPropertyName("ThumbnailType")]
+ public required ThumbnailType Type { get; set; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs
new file mode 100644
index 00000000..aea7d6f0
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailCreationRequest.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ThumbnailCreationRequest
+{
+ public required int Size { get; init; }
+
+ public required ThumbnailType Type { get; init; }
+
+ [JsonPropertyName("Hash")]
+ public required ReadOnlyMemory HashDigest { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs
new file mode 100644
index 00000000..f3740959
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDto.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ThumbnailDto
+{
+ [JsonPropertyName("ThumbnailID")]
+ public required string Id { get; init; }
+
+ public required ThumbnailType Type { get; init; }
+
+ [JsonPropertyName("Hash")]
+ public required ReadOnlyMemory HashDigest { get; init; }
+
+ [JsonPropertyName("Size")]
+ public required int SizeOnCloudStorage { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs
new file mode 100644
index 00000000..dc54aae1
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailDtoV2.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class ThumbnailDtoV2
+{
+ [JsonPropertyName("ThumbnailID")]
+ public required string Id { get; init; }
+
+ public required ThumbnailType Type { get; init; }
+
+ [JsonPropertyName("Hash")]
+ public required ReadOnlyMemory HashDigest { get; init; }
+
+ [JsonPropertyName("EncryptedSize")]
+ public required int StorageQuotaUsage { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs
new file mode 100644
index 00000000..6841ce5b
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/ThumbnailType.cs
@@ -0,0 +1,7 @@
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal enum ThumbnailType
+{
+ Thumbnail = 1,
+ Preview = 2,
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs
new file mode 100644
index 00000000..ae6b856f
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Files/TrashApiClient.cs
@@ -0,0 +1,69 @@
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Api.Volumes;
+using Proton.Drive.Sdk.Serialization;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Api;
+using Proton.Sdk.Http;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Files;
+
+internal sealed class TrashApiClient(HttpClient httpClient) : ITrashApiClient
+{
+ private readonly HttpClient _httpClient = httpClient;
+
+ public async ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.VolumeTrashResponse)
+ .GetAsync($"volumes/{volumeId}/trash?pageSize={pageSize}&page={page}", cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask> TrashMultipleAsync(
+ VolumeId volumeId,
+ MultipleLinksNullaryRequest request,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair)
+ .PostAsync($"v2/volumes/{volumeId}/trash_multiple", request, DriveApiSerializerContext.Default.MultipleLinksNullaryRequest, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask> DeleteMultipleAsync(
+ VolumeId volumeId,
+ MultipleLinksNullaryRequest request,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair)
+ .PostAsync(
+ $"v2/volumes/{volumeId}/trash/delete_multiple",
+ request,
+ DriveApiSerializerContext.Default.MultipleLinksNullaryRequest,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask> RestoreMultipleAsync(
+ VolumeId volumeId,
+ MultipleLinksNullaryRequest request,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair)
+ .PutAsync(
+ $"v2/volumes/{volumeId}/trash/restore_multiple",
+ request,
+ DriveApiSerializerContext.Default.MultipleLinksNullaryRequest,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask EmptyAsync(VolumeId volumeId, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(ProtonApiSerializerContext.Default.ApiResponse)
+ .DeleteAsync($"volumes/{volumeId}/trash", cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs
new file mode 100644
index 00000000..be7becd0
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderChildrenResponse.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Folders;
+
+internal sealed class FolderChildrenResponse : ApiResponse
+{
+ [JsonPropertyName("LinkIDs")]
+ public required IReadOnlyList LinkIds { get; init; }
+
+ [JsonPropertyName("AnchorID")]
+ public LinkId? AnchorId { get; init; }
+
+ [JsonPropertyName("More")]
+ public required bool MoreResultsExist { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs
new file mode 100644
index 00000000..64dff306
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationRequest.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Sdk.Cryptography;
+
+namespace Proton.Drive.Sdk.Api.Folders;
+
+internal sealed class FolderCreationRequest : NodeCreationRequest
+{
+ [JsonPropertyName("NodeHashKey")]
+ public required PgpArmoredMessage HashKey { get; init; }
+
+ [JsonPropertyName("SignatureEmail")]
+ public required string SignatureEmailAddress { get; init; }
+
+ [JsonPropertyName("XAttr")]
+ public PgpArmoredMessage? ExtendedAttributes { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs
new file mode 100644
index 00000000..38e28b2f
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderCreationResponse.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Folders;
+
+internal sealed class FolderCreationResponse : ApiResponse
+{
+ [JsonPropertyName("Folder")]
+ public required FolderId FolderId { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs
new file mode 100644
index 00000000..c5b2f367
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderDto.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+
+namespace Proton.Drive.Sdk.Api.Folders;
+
+internal sealed class FolderDto
+{
+ [JsonPropertyName("NodeHashKey")]
+ public required PgpArmoredMessage HashKey { get; init; }
+
+ [JsonPropertyName("XAttr")]
+ public PgpArmoredMessage? ExtendedAttributes { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs
new file mode 100644
index 00000000..b0265bba
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FolderId.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Links;
+
+namespace Proton.Drive.Sdk.Api.Folders;
+
+internal readonly struct FolderId
+{
+ [JsonPropertyName("ID")]
+ public required LinkId Value { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs
new file mode 100644
index 00000000..301483ba
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/FoldersApiClient.cs
@@ -0,0 +1,31 @@
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Serialization;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Http;
+
+namespace Proton.Drive.Sdk.Api.Folders;
+
+internal sealed class FoldersApiClient(HttpClient httpClient) : IFoldersApiClient
+{
+ private readonly HttpClient _httpClient = httpClient;
+
+ public async ValueTask GetChildrenAsync(VolumeId volumeId, LinkId linkId, LinkId? anchorId, CancellationToken cancellationToken)
+ {
+ var query = anchorId is not null ? $"?AnchorID={anchorId}" : string.Empty;
+
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.FolderChildrenResponse)
+ .GetAsync($"v2/volumes/{volumeId}/folders/{linkId}/children{query}", cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask CreateFolderAsync(
+ VolumeId volumeId,
+ FolderCreationRequest request,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.FolderCreationResponse)
+ .PostAsync($"v2/volumes/{volumeId}/folders", request, DriveApiSerializerContext.Default.FolderCreationRequest, cancellationToken)
+ .ConfigureAwait(false);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs
new file mode 100644
index 00000000..403507e5
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Folders/IFoldersApiClient.cs
@@ -0,0 +1,11 @@
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Volumes;
+
+namespace Proton.Drive.Sdk.Api.Folders;
+
+internal interface IFoldersApiClient
+{
+ ValueTask GetChildrenAsync(VolumeId volumeId, LinkId linkId, LinkId? anchorId, CancellationToken cancellationToken);
+
+ ValueTask CreateFolderAsync(VolumeId volumeId, FolderCreationRequest request, CancellationToken cancellationToken);
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs
new file mode 100644
index 00000000..f1892029
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/IDriveApiClients.cs
@@ -0,0 +1,21 @@
+using Proton.Drive.Sdk.Api.Files;
+using Proton.Drive.Sdk.Api.Folders;
+using Proton.Drive.Sdk.Api.Links;
+using Proton.Drive.Sdk.Api.Photos;
+using Proton.Drive.Sdk.Api.Shares;
+using Proton.Drive.Sdk.Api.Storage;
+using Proton.Drive.Sdk.Api.Volumes;
+
+namespace Proton.Drive.Sdk.Api;
+
+internal interface IDriveApiClients
+{
+ IVolumesApiClient Volumes { get; }
+ ISharesApiClient Shares { get; }
+ ILinksApiClient Links { get; }
+ IFoldersApiClient Folders { get; }
+ IFilesApiClient Files { get; }
+ IStorageApiClient Storage { get; }
+ ITrashApiClient Trash { get; }
+ IPhotosApiClient Photos { get; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs
new file mode 100644
index 00000000..870edc91
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ContextShareResponse.cs
@@ -0,0 +1,11 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Shares;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal class ContextShareResponse : ApiResponse
+{
+ [JsonPropertyName("ContextShareID")]
+ public required ShareId ContextShareId { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs
new file mode 100644
index 00000000..1f96c569
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ILinksApiClient.cs
@@ -0,0 +1,28 @@
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal interface ILinksApiClient
+{
+ ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken);
+
+ ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken);
+
+ ValueTask MoveAsync(VolumeId volumeId, LinkId linkId, MoveSingleLinkRequest request, CancellationToken cancellationToken);
+
+ ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken);
+
+ ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken);
+
+ ValueTask> DeleteMultipleAsync(
+ VolumeId volumeId,
+ IEnumerable linkIds,
+ CancellationToken cancellationToken);
+
+ ValueTask GetAvailableNames(
+ VolumeId volumeId,
+ LinkId folderId,
+ NodeNameAvailabilityRequest request,
+ CancellationToken cancellationToken);
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs
new file mode 100644
index 00000000..1071b617
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/ITrashApiClient.cs
@@ -0,0 +1,27 @@
+using Proton.Drive.Sdk.Api.Volumes;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal interface ITrashApiClient
+{
+ ValueTask GetTrashAsync(VolumeId volumeId, int pageSize, int page, CancellationToken cancellationToken);
+
+ ValueTask> TrashMultipleAsync(
+ VolumeId volumeId,
+ MultipleLinksNullaryRequest request,
+ CancellationToken cancellationToken);
+
+ ValueTask> RestoreMultipleAsync(
+ VolumeId volumeId,
+ MultipleLinksNullaryRequest request,
+ CancellationToken cancellationToken);
+
+ ValueTask> DeleteMultipleAsync(
+ VolumeId volumeId,
+ MultipleLinksNullaryRequest request,
+ CancellationToken cancellationToken);
+
+ ValueTask EmptyAsync(VolumeId volumeId, CancellationToken cancellationToken);
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs
new file mode 100644
index 00000000..d639aaaf
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsDto.cs
@@ -0,0 +1,34 @@
+using Proton.Drive.Sdk.Api.Files;
+using Proton.Drive.Sdk.Api.Folders;
+using Proton.Drive.Sdk.Api.Photos;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class LinkDetailsDto
+{
+ public required LinkDto Link { get; init; }
+ public FolderDto? Folder { get; init; }
+ public FileDto? File { get; init; }
+ public PhotoDto? Photo { get; init; }
+ public FolderDto? Album { get; init; }
+ public LinkSharingDto? Sharing { get; init; }
+ public ShareMembershipSummaryDto? Membership { get; init; }
+
+ public void Deconstruct(
+ out LinkDto link,
+ out FolderDto? folder,
+ out FileDto? file,
+ out PhotoDto? photo,
+ out FolderDto? album,
+ out LinkSharingDto? sharing,
+ out ShareMembershipSummaryDto? membership)
+ {
+ link = Link;
+ folder = Folder;
+ file = File;
+ photo = Photo;
+ album = Album;
+ sharing = Sharing;
+ membership = Membership;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs
new file mode 100644
index 00000000..f147243c
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsRequest.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal readonly struct LinkDetailsRequest(IEnumerable linkIds)
+{
+ [JsonPropertyName("LinkIDs")]
+ public IEnumerable LinkIds { get; } = linkIds;
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs
new file mode 100644
index 00000000..75bdb279
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDetailsResponse.cs
@@ -0,0 +1,8 @@
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class LinkDetailsResponse : ApiResponse
+{
+ public required IReadOnlyList Links { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs
new file mode 100644
index 00000000..57c1a304
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkDto.cs
@@ -0,0 +1,54 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class LinkDto
+{
+ [JsonPropertyName("LinkID")]
+ public LinkId Id { get; init; }
+
+ public LinkType Type { get; init; }
+
+ [JsonPropertyName("ParentLinkID")]
+ public LinkId? ParentId { get; init; }
+
+ public required LinkState State { get; init; }
+
+ [JsonPropertyName("CreateTime")]
+ [JsonConverter(typeof(EpochSecondsJsonConverter))]
+ public required DateTime CreationTime { get; init; }
+
+ [JsonPropertyName("ModifyTime")]
+ [JsonConverter(typeof(EpochSecondsJsonConverter))]
+ public required DateTime ModificationTime { get; init; }
+
+ [JsonPropertyName("Trashed")]
+ [JsonConverter(typeof(EpochSecondsJsonConverter))]
+ public DateTime? TrashTime { get; init; }
+
+ public required PgpArmoredMessage Name { get; init; }
+
+ [JsonPropertyName("NameHash")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public required ReadOnlyMemory NameHashDigest { get; init; }
+
+ [JsonPropertyName("NodeKey")]
+ public required PgpArmoredPrivateKey Key { get; init; }
+
+ [JsonPropertyName("NodePassphrase")]
+ public required PgpArmoredMessage Passphrase { get; init; }
+
+ [JsonPropertyName("NodePassphraseSignature")]
+ public PgpArmoredSignature? PassphraseSignature { get; init; }
+
+ [JsonPropertyName("SignatureEmail")]
+ public string? SignatureEmailAddress { get; init; }
+
+ [JsonPropertyName("NameSignatureEmail")]
+ public string? NameSignatureEmailAddress { get; init; }
+
+ [JsonPropertyName("OwnedBy")]
+ public OwnedByDto? OwnedBy { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs
new file mode 100644
index 00000000..e666cdd7
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkId.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+[JsonConverter(typeof(StrongIdJsonConverter))]
+internal readonly record struct LinkId : IStrongId
+{
+ private readonly string? _value;
+
+ internal LinkId(string? value)
+ {
+ _value = value;
+ }
+
+ public static explicit operator LinkId(string? value)
+ {
+ return new LinkId(value);
+ }
+
+ public override string ToString()
+ {
+ return _value ?? string.Empty;
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs
new file mode 100644
index 00000000..9ba8cd62
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkIdResponsePair.cs
@@ -0,0 +1,6 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Api;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal readonly record struct LinkIdResponsePair([property: JsonPropertyName("LinkID")] LinkId LinkId, ApiResponse Response);
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkSharingDto.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkSharingDto.cs
new file mode 100644
index 00000000..5bb34da7
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkSharingDto.cs
@@ -0,0 +1,10 @@
+using System.Text.Json.Serialization;
+using Proton.Drive.Sdk.Api.Shares;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class LinkSharingDto
+{
+ [JsonPropertyName("ShareID")]
+ public required ShareId ShareId { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs
new file mode 100644
index 00000000..10cb2935
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkState.cs
@@ -0,0 +1,32 @@
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal enum LinkState
+{
+ ///
+ /// File is created, waiting for the revision to be committed.
+ /// Automatically garbage collected if no blocks uploaded within last 3 hours.
+ ///
+ Draft = 0,
+
+ ///
+ /// Active
+ ///
+ Active = 1,
+
+ ///
+ /// Trashed
+ ///
+ Trashed = 2,
+
+ ///
+ /// Permanently deleted, waiting for the garbage collection.
+ /// Should not appear in API responses.
+ ///
+ Deleted = 3,
+
+ ///
+ /// Hidden, being restored from old volume.
+ /// Should not appear in API responses.
+ ///
+ Restoring = 4,
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs
new file mode 100644
index 00000000..76c0a2db
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinkType.cs
@@ -0,0 +1,8 @@
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal enum LinkType
+{
+ Folder = 1,
+ File = 2,
+ Album = 3,
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs
new file mode 100644
index 00000000..406e8363
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/LinksApiClient.cs
@@ -0,0 +1,83 @@
+using Proton.Drive.Sdk.Serialization;
+using Proton.Drive.Sdk.Volumes;
+using Proton.Sdk.Api;
+using Proton.Sdk.Http;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class LinksApiClient(HttpClient httpClient) : ILinksApiClient
+{
+ private readonly HttpClient _httpClient = httpClient;
+
+ public async ValueTask GetDetailsAsync(VolumeId volumeId, IEnumerable linkIds, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.LinkDetailsResponse)
+ .PostAsync($"v2/volumes/{volumeId}/links", new LinkDetailsRequest(linkIds), DriveApiSerializerContext.Default.LinkDetailsRequest, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ // FIXME use recursive lookup instead, remove this
+ public async ValueTask GetContextShareAsync(VolumeId volumeId, LinkId linkId, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.ContextShareResponse)
+ .GetAsync($"volumes/{volumeId}/links/{linkId}/context", cancellationToken).ConfigureAwait(false);
+ }
+
+ public async ValueTask MoveAsync(VolumeId volumeId, LinkId linkId, MoveSingleLinkRequest request, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(ProtonApiSerializerContext.Default.ApiResponse)
+ .PutAsync($"v2/volumes/{volumeId}/links/{linkId}/move", request, DriveApiSerializerContext.Default.MoveSingleLinkRequest, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask MoveMultipleAsync(VolumeId volumeId, MoveMultipleLinksRequest request, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(ProtonApiSerializerContext.Default.ApiResponse)
+ .PutAsync($"volumes/{volumeId}/links/move-multiple", request, DriveApiSerializerContext.Default.MoveMultipleLinksRequest, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask RenameAsync(VolumeId volumeId, LinkId linkId, RenameLinkRequest request, CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(ProtonApiSerializerContext.Default.ApiResponse)
+ .PutAsync($"v2/volumes/{volumeId}/links/{linkId}/rename", request, DriveApiSerializerContext.Default.RenameLinkRequest, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask> DeleteMultipleAsync(
+ VolumeId volumeId,
+ IEnumerable linkIds,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.AggregateApiResponseLinkIdResponsePair)
+ .PostAsync(
+ $"v2/volumes/{volumeId}/delete_multiple",
+ new MultipleLinksNullaryRequest { LinkIds = linkIds },
+ DriveApiSerializerContext.Default.MultipleLinksNullaryRequest,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ public async ValueTask GetAvailableNames(
+ VolumeId volumeId,
+ LinkId folderId,
+ NodeNameAvailabilityRequest request,
+ CancellationToken cancellationToken)
+ {
+ return await _httpClient
+ .Expecting(DriveApiSerializerContext.Default.NodeNameAvailabilityResponse)
+ .PostAsync(
+ $"v2/volumes/{volumeId}/links/{folderId}/checkAvailableHashes",
+ request,
+ DriveApiSerializerContext.Default.NodeNameAvailabilityRequest,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs
new file mode 100644
index 00000000..8806bb72
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksItem.cs
@@ -0,0 +1,28 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class MoveMultipleLinksItem
+{
+ [JsonPropertyName("LinkID")]
+ public required LinkId LinkId { get; init; }
+
+ public required PgpArmoredMessage Name { get; init; }
+
+ [JsonPropertyName("NodePassphrase")]
+ public required PgpArmoredMessage Passphrase { get; init; }
+
+ [JsonPropertyName("Hash")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public required ReadOnlyMemory NameHashDigest { get; init; }
+
+ [JsonPropertyName("OriginalHash")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public required ReadOnlyMemory OriginalNameHashDigest { get; init; }
+
+ [JsonPropertyName("NodePassphraseSignature")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public required PgpArmoredSignature? PassphraseSignature { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs
new file mode 100644
index 00000000..38c4cbd7
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveMultipleLinksRequest.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class MoveMultipleLinksRequest
+{
+ [JsonPropertyName("ParentLinkID")]
+ public required LinkId ParentLinkId { get; init; }
+
+ [JsonPropertyName("Links")]
+ public required IReadOnlyList Batch { get; init; }
+
+ [JsonPropertyName("NameSignatureEmail")]
+ public required string NameSignatureEmailAddress { get; init; }
+
+ [JsonPropertyName("SignatureEmail")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public required string? SignatureEmailAddress { get; init; }
+}
diff --git a/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs
new file mode 100644
index 00000000..96114135
--- /dev/null
+++ b/cs/sdk/src/Proton.Drive.Sdk/Api/Links/MoveSingleLinkRequest.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Serialization;
+using Proton.Sdk.Cryptography;
+using Proton.Sdk.Serialization;
+
+namespace Proton.Drive.Sdk.Api.Links;
+
+internal sealed class MoveSingleLinkRequest
+{
+ public required PgpArmoredMessage Name { get; init; }
+
+ [JsonPropertyName("NodePassphrase")]
+ public required PgpArmoredMessage Passphrase { get; init; }
+
+ [JsonPropertyName("Hash")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public required ReadOnlyMemory NameHashDigest { get; init; }
+
+ [JsonPropertyName("ParentLinkID")]
+ public required LinkId ParentLinkId { get; init; }
+
+ [JsonPropertyName("OriginalHash")]
+ [JsonConverter(typeof(ForgivingBytesToHexJsonConverter))]
+ public required ReadOnlyMemory