14 Commits

Author SHA1 Message Date
Hammed Abass
ad9ffd63f5 Merge pull request #27 from sudosylabs/fix/typed-error-propagation
fix(core): preserve typed transfer failures
2026-07-22 21:00:06 +02:00
82fb549576 fix(core): preserve typed transfer failures 2026-07-22 20:43:15 +02:00
Hammed Abass
c6e59bed26 Merge pull request #26 from sudosylabs/feat/native-platform-icons
feat(ui): add native platform icon sets
2026-07-22 19:58:36 +02:00
58c470b279 fix(ci): isolate Linux Gradle caches 2026-07-22 19:02:01 +02:00
4e2777b917 feat(ui): add native platform icon sets 2026-07-22 18:47:23 +02:00
Hammed Abass
b027177ad3 Merge pull request #25 from sudosylabs/feat/storage-accounting
fix(storage): reclaim transfer cache and track received files
2026-07-22 17:33:25 +02:00
Hammed Abass
6388d42ec1 Merge pull request #24 from sudosylabs/feat/native-platform-ui
feat(ui): add native platform experiences
2026-07-22 16:35:46 +02:00
b46c5e7d72 fix(storage): reclaim transfer cache and track received files 2026-07-22 16:03:37 +02:00
be7a61e948 feat(desktop): integrate native Windows titlebar 2026-07-22 15:49:41 +02:00
0e97c014a1 feat(desktop): add native Windows window controller 2026-07-22 15:49:11 +02:00
0ab076e5b2 feat(ui): support native desktop backdrops 2026-07-22 15:48:16 +02:00
a0d352895e feat(ui): align Android empty states 2026-07-22 12:26:35 +02:00
cf7734fb42 feat(desktop): polish Linux native experience 2026-07-22 11:23:10 +02:00
627c205853 feat(ui): adapt presentation to each platform 2026-07-22 10:04:32 +02:00
216 changed files with 6236 additions and 1101 deletions

View File

@@ -69,6 +69,8 @@ jobs:
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Set up Rust 1.91 - name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
@@ -172,6 +174,8 @@ jobs:
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Set up Rust 1.91 - name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1

View File

@@ -62,6 +62,10 @@ final class FakeCoreGateway: CoreGateway {
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult } func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult } func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult } func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
.success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0))
}
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) } func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> { func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
responses.append((requestId, accepted, reason)) responses.append((requestId, accepted, reason))

View File

@@ -1,4 +1,5 @@
import XCTest import XCTest
import VnidropCore
@testable import VniDrop @testable import VniDrop
/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`. /// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`.
@@ -50,4 +51,15 @@ final class UserFacingErrorTests: XCTestCase {
func testToUiTextFallsBackToGeneric() { func testToUiTextFallsBackToGeneric() {
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic")) XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic"))
} }
func testToUiTextMapsTypedTransferFailures() {
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource("error_filesystem"))
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource("error_destination_exists"))
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource("error_storage_full"))
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource("error_network"))
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource("error_invalid_input"))
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
}
} }

View File

@@ -2,6 +2,16 @@ import Foundation
import Combine import Combine
import VnidropCore import VnidropCore
struct CoreStorageUsageModel: Sendable {
let blobStoreBytes: UInt64
let appDataBytes: UInt64
}
struct ReceivedArtifactModel: Sendable {
let locator: String
let logicalSize: UInt64
}
/// Seam between the feature models and the Rust core, mirroring `CoreGateway` /// Seam between the feature models and the Rust core, mirroring `CoreGateway`
/// in the KMP `shared` module. `CoreRepository` is the production implementation; /// in the KMP `shared` module. `CoreRepository` is the production implementation;
/// tests substitute a fake so the models can be exercised without the FFI. /// tests substitute a fake so the models can be exercised without the FFI.
@@ -32,6 +42,8 @@ protocol CoreGateway: AnyObject {
func cancel(transferId: UInt64) async -> Result<Void, Error> func cancel(transferId: UInt64) async -> Result<Void, Error>
func delete(transferId: UInt64) async -> Result<Void, Error> func delete(transferId: UInt64) async -> Result<Void, Error>
func clearReceiveHistory() async -> Result<UInt64, Error> func clearReceiveHistory() async -> Result<UInt64, Error>
func storageUsage() async -> Result<CoreStorageUsageModel, Error>
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error>
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error>
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error>
func refresh() async -> Result<Void, Error> func refresh() async -> Result<Void, Error>

View File

@@ -143,6 +143,25 @@ final class CoreRepository: ObservableObject, CoreGateway {
} }
} }
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
await runCore {
let usage = try self.requireCore().storageUsage()
return CoreStorageUsageModel(
blobStoreBytes: usage.blobStoreBytes,
appDataBytes: usage.databaseBytes + usage.logsBytes + usage.previewsBytes + usage.otherCoreBytes
)
}
}
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> {
await runCore {
try self.requireCore().listReceivedArtifacts().compactMap { artifact in
guard artifact.locatorKind == .filesystemPath else { return nil }
return ReceivedArtifactModel(locator: artifact.locator, logicalSize: artifact.logicalSize)
}
}
}
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
await runCore { await runCore {
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() } try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }

View File

@@ -192,8 +192,8 @@ final class ReceiveModel: ObservableObject {
messages.tryShow(UiMessage( messages.tryShow(UiMessage(
text: uiText, text: uiText,
tone: .error, tone: .error,
actionLabel: .resource("button_retry"), actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil,
onAction: { self.receive() } onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil
)) ))
} }
} }

View File

@@ -27,9 +27,10 @@ enum SettingsSection: Hashable {
/// On-disk usage breakdown for the Storage screen. /// On-disk usage breakdown for the Storage screen.
struct StorageBreakdown: Equatable { struct StorageBreakdown: Equatable {
var receivedFiles: UInt64 = 0 var receivedFiles: UInt64 = 0
var transferData: UInt64 = 0 var transferCache: UInt64 = 0
var appData: UInt64 = 0
var temporary: UInt64 = 0 var temporary: UInt64 = 0
var total: UInt64 { receivedFiles + transferData + temporary } var total: UInt64 { receivedFiles + transferCache + appData + temporary }
} }
struct SettingsState: Equatable { struct SettingsState: Equatable {
@@ -286,19 +287,29 @@ final class SettingsModel: ObservableObject {
func loadStorageUsage() { func loadStorageUsage() {
if state.isCalculatingStorage { return } if state.isCalculatingStorage { return }
state.isCalculatingStorage = true state.isCalculatingStorage = true
let coreDir = environment.defaultCoreDataDir
let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil
let tempDir = NSTemporaryDirectory() let tempDir = NSTemporaryDirectory()
Task.detached { Task {
let breakdown = StorageBreakdown( let coreResult = await repository.storageUsage()
receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0, let artifactsResult = await repository.receivedArtifacts()
transferData: SettingsModel.directorySize(coreDir), guard case .success(let core) = coreResult,
temporary: SettingsModel.directorySize(tempDir) case .success(let artifacts) = artifactsResult else {
) state.isCalculatingStorage = false
await MainActor.run { [weak self] in return
self?.state.storage = breakdown
self?.state.isCalculatingStorage = false
} }
let diskSizes = await Task.detached {
let received = artifacts.reduce(UInt64(0)) { total, artifact in
total + SettingsModel.fileSize(artifact.locator)
}
return (received, SettingsModel.directorySize(tempDir))
}.value
let breakdown = StorageBreakdown(
receivedFiles: diskSizes.0,
transferCache: core.blobStoreBytes,
appData: core.appDataBytes,
temporary: diskSizes.1
)
state.storage = breakdown
state.isCalculatingStorage = false
} }
} }
@@ -309,15 +320,28 @@ final class SettingsModel: ObservableObject {
if state.isDeletingTransfers { return } if state.isDeletingTransfers { return }
state.isDeletingTransfers = true state.isDeletingTransfers = true
Task { Task {
var failures = 0
for id in repository.state.transfers.map(\.transferId) { for id in repository.state.transfers.map(\.transferId) {
_ = await repository.delete(transferId: id) if case .failure = await repository.delete(transferId: id) { failures += 1 }
} }
_ = await repository.refresh() _ = await repository.refresh()
state.isDeletingTransfers = false state.isDeletingTransfers = false
if failures == 0 {
loadStorageUsage() loadStorageUsage()
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success))
} else {
messages.error(InvitationError.message("Could not delete \(failures) transfer records"))
} }
} }
}
nonisolated static func fileSize(_ path: String) -> UInt64 {
let values = try? URL(fileURLWithPath: path).resourceValues(
forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]
)
guard values?.isRegularFile == true else { return 0 }
return UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0)
}
/// Recursive size of every regular file under `path` (0 if missing). /// Recursive size of every regular file under `path` (0 if missing).
nonisolated static func directorySize(_ path: String) -> UInt64 { nonisolated static func directorySize(_ path: String) -> UInt64 {

View File

@@ -65,7 +65,8 @@ struct StorageSettings: View {
Section { Section {
if let storage = model.state.storage { if let storage = model.state.storage {
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles))
LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferData)) LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferCache))
LabeledContent(String(localized: "storage_app_data"), value: formatBytes(storage.appData))
LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary)) LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary))
LabeledContent(String(localized: "storage_total")) { LabeledContent(String(localized: "storage_total")) {
Text(formatBytes(storage.total)).fontWeight(.semibold) Text(formatBytes(storage.total)).fontWeight(.semibold)

View File

@@ -5109,6 +5109,66 @@
} }
} }
}, },
"error_destination_exists": {
"comment": "Error: a received file would overwrite an existing destination file.",
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "needs_review",
"value": "Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "A file with the same name already exists in the destination. Choose another folder or remove the existing file."
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente."
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant."
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente."
}
},
"nl": {
"stringUnit": {
"state": "needs_review",
"value": "Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand."
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik."
}
},
"pt": {
"stringUnit": {
"state": "needs_review",
"value": "Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente."
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл."
}
}
}
},
"error_device_info": { "error_device_info": {
"comment": "Error: device information could not be loaded.", "comment": "Error: device information could not be loaded.",
"extractionState": "manual", "extractionState": "manual",
@@ -5349,6 +5409,66 @@
} }
} }
}, },
"error_invalid_input": {
"comment": "Error: transfer input or metadata is invalid.",
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "needs_review",
"value": "Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Some transfer information is invalid. Review your selection or ask the sender to share again."
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla."
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à lexpéditeur de partager à nouveau."
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo."
}
},
"nl": {
"stringUnit": {
"state": "needs_review",
"value": "Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen."
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie."
}
},
"pt": {
"stringUnit": {
"state": "needs_review",
"value": "Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente."
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова."
}
}
}
},
"error_invalid_ticket": { "error_invalid_ticket": {
"comment": "Error: the invitation/ticket could not be parsed.", "comment": "Error: the invitation/ticket could not be parsed.",
"extractionState": "manual", "extractionState": "manual",
@@ -5529,6 +5649,66 @@
} }
} }
}, },
"error_network": {
"comment": "Error: the sender could not be reached over the local network.",
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "needs_review",
"value": "VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "VniDrop could not reach the sender. Check the connection on both devices and try again."
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo."
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "VniDrop na pas pu joindre lexpéditeur. Vérifiez la connexion sur les deux appareils et réessayez."
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi."
}
},
"nl": {
"stringUnit": {
"state": "needs_review",
"value": "VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw."
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie."
}
},
"pt": {
"stringUnit": {
"state": "needs_review",
"value": "O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente."
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку."
}
}
}
},
"error_nfc": { "error_nfc": {
"comment": "Error: the NFC tag could not be read/used.", "comment": "Error: the NFC tag could not be read/used.",
"extractionState": "manual", "extractionState": "manual",
@@ -5949,62 +6129,122 @@
} }
} }
}, },
"error_transfer": { "error_storage_full": {
"comment": "Error: the transfer could not be completed.", "comment": "Error: the destination does not have enough free storage.",
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Die Übertragung konnte nicht abgeschlossen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut." "value": "Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut."
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "The transfer could not be completed. Check your connection and try again." "value": "There is not enough storage space to save this transfer. Free up space and try again."
} }
}, },
"es": { "es": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "No se pudo completar la transferencia. Compruebe su conexión e inténtelo de nuevo." "value": "No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo."
} }
}, },
"fr": { "fr": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Le transfert na pas pu être terminé. Vérifiez votre connexion et réessayez." "value": "Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez."
} }
}, },
"it": { "it": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Impossibile completare il trasferimento. Controlli la connessione e riprovi." "value": "Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi."
} }
}, },
"nl": { "nl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "De overdracht kon niet worden voltooid. Controleer uw verbinding en probeer het opnieuw." "value": "Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw."
} }
}, },
"pl": { "pl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Nie udało się ukończyć transferu. Sprawdź połączenie i spróbuj ponownie." "value": "Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie."
} }
}, },
"pt": { "pt": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Não foi possível concluir a transferência. Verifique a sua ligão e tente novamente." "value": "Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente."
} }
}, },
"ru": { "ru": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Не удалось завершить передачу. Проверьте подключение и повторите попытку." "value": "Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку."
}
}
}
},
"error_transfer": {
"comment": "Error: transfer data could not be processed; network failures use error_network.",
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "needs_review",
"value": "Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The transfer data could not be processed. Ask the sender to share it again."
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos."
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Les données du transfert nont pas pu être traitées. Demandez à lexpéditeur de les partager à nouveau."
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo."
}
},
"nl": {
"stringUnit": {
"state": "needs_review",
"value": "De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen."
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie."
}
},
"pt": {
"stringUnit": {
"state": "needs_review",
"value": "Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente."
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
} }
} }
} }
@@ -12009,6 +12249,66 @@
} }
} }
}, },
"storage_app_data": {
"comment": "Settings > Storage: label for non-transfer application data.",
"extractionState": "manual",
"localizations": {
"de": {
"stringUnit": {
"state": "needs_review",
"value": "App-Daten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "App data"
}
},
"es": {
"stringUnit": {
"state": "needs_review",
"value": "Datos de la aplicación"
}
},
"fr": {
"stringUnit": {
"state": "needs_review",
"value": "Données de lapp"
}
},
"it": {
"stringUnit": {
"state": "needs_review",
"value": "Dati dellapp"
}
},
"nl": {
"stringUnit": {
"state": "needs_review",
"value": "Appgegevens"
}
},
"pl": {
"stringUnit": {
"state": "needs_review",
"value": "Dane aplikacji"
}
},
"pt": {
"stringUnit": {
"state": "needs_review",
"value": "Dados da aplicação"
}
},
"ru": {
"stringUnit": {
"state": "needs_review",
"value": "Данные приложения"
}
}
}
},
"storage_calculating": { "storage_calculating": {
"comment": "Settings > Storage: placeholder while a size is being calculated.", "comment": "Settings > Storage: placeholder while a size is being calculated.",
"extractionState": "manual", "extractionState": "manual",
@@ -12136,55 +12436,55 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Dies löscht alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf. Ihre empfangenen Dateien werden nicht gelöscht. Hinweis: Die Übertragungs-Engine behält ihre gespeicherten Inhalte, sodass die Übertragungsdaten möglicherweise nicht abnehmen. Dies kann nicht rückgängig gemacht werden." "value": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden."
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This cant be undone." "value": "This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This cant be undone."
} }
}, },
"es": { "es": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Esto borra todos los registros de transferencias enviadas y recibidas de su historial. Sus archivos recibidos no se eliminan. Nota: el motor de transferencia conserva su contenido almacenado, por lo que los datos de transferencia pueden no disminuir. Esto no se puede deshacer." "value": "Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer."
} }
}, },
"fr": { "fr": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Cela efface tous les enregistrements de transferts envoyés et reçus de votre historique. Vos fichiers reçus ne sont pas supprimés. Remarque : le moteur de transfert conserve son contenu stocké, donc les données de transfert peuvent ne pas diminuer. Cette action est irréversible." "value": "Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui nest plus cessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible."
} }
}, },
"it": { "it": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I suoi file ricevuti non vengono eliminati. Nota: il motore di trasferimento conserva il contenuto memorizzato, quindi i dati di trasferimento potrebbero non diminuire. Questa operazione non può essere annullata." "value": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po di tempo. Questa azione non può essere annullata."
} }
}, },
"nl": { "nl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Let op: de overdrachtsengine bewaart de opgeslagen inhoud, dus de overdrachtsgegevens nemen mogelijk niet af. Dit kan niet ongedaan worden gemaakt." "value": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt."
} }
}, },
"pl": { "pl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "To usuwa z historii wszystkie rekordy wysłanych i odebranych transferów. Twoje odebrane pliki nie są usuwane. Uwaga: silnik transferu zachowuje przechowywaną zawartość, więc dane transferu mogą się nie zmniejszyć. Tej operacji nie można cofnąć." "value": "Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć."
} }
}, },
"pt": { "pt": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Isto elimina do seu histórico todos os registos de transferências enviadas e recebidas. Os seus ficheiros recebidos não são eliminados. Nota: o motor de transferência mantém o conteúdo armazenado, pelo que os dados de transferência podem não diminuir. Isto não pode ser anulado." "value": "Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada."
} }
}, },
"ru": { "ru": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить." "value": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить."
} }
} }
} }
@@ -12256,55 +12556,55 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Übertragungsdaten werden von der Übertragungs-Engine verwaltet Ihr Verlauf sowie der Inhalt der von Ihnen geteilten Dateien. Die Schaltfläche unten löscht Ihre Übertragungsdatensätze; die Engine behält ihre gespeicherten Inhalte, sodass dieser Wert möglicherweise nicht sinkt. Empfangene Dateien sind Ihre heruntergeladenen Dateien und werden hier niemals gelöscht." "value": "Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht."
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Transfer data is managed by the transfer engine — your history plus the content of files youve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here." "value": "Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here."
} }
}, },
"es": { "es": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Los datos de transferencia los gestiona el motor de transferencia: su historial más el contenido de los archivos que ha compartido. El botón de abajo borra sus registros de transferencias; el motor conserva su contenido almacenado, por lo que este valor puede no bajar. Los archivos recibidos son los archivos que ha descargado y nunca se eliminan aquí." "value": "Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí."
} }
}, },
"fr": { "fr": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Les données de transfert sont gérées par le moteur de transfert — votre historique ainsi que le contenu des fichiers que vous avez partagés. Le bouton ci-dessous efface vos enregistrements de transferts ; le moteur conserve son contenu stocké, donc cette valeur peut ne pas baisser. Les fichiers reçus sont vos fichiers téléchargés et ne sont jamais supprimés ici." "value": "Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici."
} }
}, },
"it": { "it": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "I dati di trasferimento sono gestiti dal motore di trasferimento: la sua cronologia più il contenuto dei file che ha condiviso. Il pulsante qui sotto cancella i record dei trasferimenti; il motore conserva il contenuto memorizzato, quindi questo valore potrebbe non diminuire. I file ricevuti sono i file che ha scaricato e non vengono mai eliminati qui." "value": "I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui."
} }
}, },
"nl": { "nl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Overdrachtsgegevens worden beheerd door de overdrachtsengine — uw geschiedenis plus de inhoud van de bestanden die u hebt gedeeld. De knop hieronder wist uw overdrachtsrecords; de engine bewaart de opgeslagen inhoud, dus deze waarde daalt mogelijk niet. Ontvangen bestanden zijn uw gedownloade bestanden en worden hier nooit verwijderd." "value": "Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd."
} }
}, },
"pl": { "pl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Danymi transferu zarządza silnik transferu — Twoja historia oraz zawartość udostępnionych plików. Przycisk poniżej usuwa rekordy transferów; silnik zachowuje przechowywaną zawartość, więc ta wartość może się nie zmniejszyć. Odebrane pliki to Twoje pobrane pliki i nigdy nie są tu usuwane." "value": "Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane."
} }
}, },
"pt": { "pt": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Os dados de transferência são geridos pelo motor de transferência — o seu histórico mais o conteúdo dos ficheiros que partilhou. O botão abaixo elimina os seus registos de transferências; o motor mantém o conteúdo armazenado, pelo que este valor pode não descer. Os ficheiros recebidos são os ficheiros que descarregou e nunca são eliminados aqui." "value": "Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui."
} }
}, },
"ru": { "ru": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь." "value": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь."
} }
} }
} }

View File

@@ -13,10 +13,22 @@ extension Error {
return .resource("error_permission") return .resource("error_permission")
case .Filesystem: case .Filesystem:
return .resource("error_filesystem") return .resource("error_filesystem")
case .FilesystemPermission:
return .resource("error_filesystem")
case .DestinationExists:
return .resource("error_destination_exists")
case .StorageFull:
return .resource("error_storage_full")
case .Network:
return .resource("error_network")
case .Transfer(let reason): case .Transfer(let reason):
return transferUiText(reason) return transferUiText(reason)
case .Repository: case .Repository:
return .resource("error_repository") return .resource("error_repository")
case .Cancelled:
return .resource("error_generic")
case .InvalidInput:
return .resource("error_invalid_input")
case .Initialization(let reason): case .Initialization(let reason):
return initializationUiText(reason) return initializationUiText(reason)
case .Internal(let reason): case .Internal(let reason):
@@ -28,6 +40,7 @@ extension Error {
/// True when the user intentionally backed out of a flow. /// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool { var isUserCancellation: Bool {
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
let haystack = technicalDetail.lowercased() let haystack = technicalDetail.lowercased()
if haystack.isEmpty { if haystack.isEmpty {
// URLError / CocoaError cancellation without a message. // URLError / CocoaError cancellation without a message.
@@ -44,13 +57,23 @@ extension Error {
var technicalDetail: String { var technicalDetail: String {
if let vni = self as? VnidropError { if let vni = self as? VnidropError {
switch vni { switch vni {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Internal(let r): .DestinationExists(let r), .StorageFull(let r), .Network(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
.InvalidInput(let r), .Internal(let r):
return r return r
} }
} }
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
} }
var canRetryWithoutChangingInput: Bool {
guard let vni = self as? VnidropError else { return true }
switch vni {
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
default: return true
}
}
} }
private func transferUiText(_ reason: String) -> UiText { private func transferUiText(_ reason: String) -> UiText {

View File

@@ -82,10 +82,14 @@ bytes through Kotlin memory.
## Blob Retention Policy ## Blob Retention Policy
Stopping a share immediately removes its provider mapping and approval state, Stopping a share immediately removes its provider mapping and approval state,
so outstanding VniDrop tickets can no longer download content. Physical blob chunks are so outstanding VniDrop tickets can no longer download content. Physical blob
not force-deleted at stop time because content-addressed chunks may be shared by chunks are not force-deleted at stop time because content-addressed chunks may be
another active collection. They remain eligible for the blob store's garbage shared by another active collection. Every active outgoing share has a persistent
collection. Restart reconciliation never restores a stopped share. `vnidrop/share/<local-id>` tag; stopping or deleting it removes that tag, and the
configured garbage collector later reclaims content with no remaining persistent
or temporary tag. Receive downloads keep a temporary tag through export and become
reclaimable after publication. Restart reconciliation repairs active-share tags,
removes orphan share tags, and never restores a stopped share.
## Resource Limits ## Resource Limits

View File

@@ -127,6 +127,60 @@ pub trait ReceiveOutputSink: Send + Sync {
) -> Result<(), crate::error::VnidropError>; ) -> Result<(), crate::error::VnidropError>;
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
pub enum ReceivedLocatorKind {
FilesystemPath,
AndroidMediaStore,
AndroidDocument,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct PublishedOutput {
pub locator_kind: ReceivedLocatorKind,
pub locator: String,
}
/// Versioned receive sink that reports the durable locator created at publish time.
#[uniffi::export(with_foreign)]
pub trait ReceiveOutputSinkV2: Send + Sync {
fn start_file(&self, relative_path: String) -> Result<(), crate::error::VnidropError>;
fn write_chunk(
&self,
relative_path: String,
bytes: Vec<u8>,
) -> Result<(), crate::error::VnidropError>;
fn finish_file(
&self,
relative_path: String,
) -> Result<PublishedOutput, crate::error::VnidropError>;
fn abort_file(
&self,
relative_path: String,
reason: String,
) -> Result<(), crate::error::VnidropError>;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct ReceivedArtifact {
pub id: String,
pub transfer_local_id: String,
pub protocol_transfer_id: u64,
pub relative_path: String,
pub locator_kind: ReceivedLocatorKind,
pub locator: String,
pub logical_size: u64,
pub published_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
pub struct CoreStorageUsage {
pub blob_store_bytes: u64,
pub database_bytes: u64,
pub logs_bytes: u64,
pub previews_bytes: u64,
pub other_core_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
pub struct RuntimeStatus { pub struct RuntimeStatus {
pub endpoint_id: String, pub endpoint_id: String,

View File

@@ -8,12 +8,24 @@ pub enum VnidropError {
Ticket { reason: String }, Ticket { reason: String },
#[error("filesystem error: {reason}")] #[error("filesystem error: {reason}")]
Filesystem { reason: String }, Filesystem { reason: String },
#[error("filesystem permission denied: {reason}")]
FilesystemPermission { reason: String },
#[error("destination already exists: {reason}")]
DestinationExists { reason: String },
#[error("storage is full: {reason}")]
StorageFull { reason: String },
#[error("network error: {reason}")]
Network { reason: String },
#[error("transfer error: {reason}")] #[error("transfer error: {reason}")]
Transfer { reason: String }, Transfer { reason: String },
#[error("permission error: {reason}")] #[error("permission error: {reason}")]
Permission { reason: String }, Permission { reason: String },
#[error("repository error: {reason}")] #[error("repository error: {reason}")]
Repository { reason: String }, Repository { reason: String },
#[error("operation cancelled: {reason}")]
Cancelled { reason: String },
#[error("invalid input: {reason}")]
InvalidInput { reason: String },
#[error("internal error: {reason}")] #[error("internal error: {reason}")]
Internal { reason: String }, Internal { reason: String },
} }
@@ -32,42 +44,138 @@ impl VnidropError {
} }
pub(crate) fn filesystem(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn filesystem(error: impl Into<anyhow::Error>) -> Self {
Self::Filesystem { let error = error.into();
reason: error.into().to_string(), Self::classify(error, |reason| Self::Filesystem { reason })
} }
pub(crate) fn network(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::Network { reason })
} }
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
Self::Transfer { let error = error.into();
reason: error.into().to_string(), Self::classify(error, |reason| Self::Transfer { reason })
}
} }
pub(crate) fn permission(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn permission(error: impl Into<anyhow::Error>) -> Self {
Self::Permission { Self::classify(error.into(), |reason| Self::Permission { reason })
reason: error.into().to_string(),
}
} }
pub(crate) fn repository(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn repository(error: impl Into<anyhow::Error>) -> Self {
Self::Repository { Self::from_error(error.into(), |reason| Self::Repository { reason })
reason: error.into().to_string(), }
pub(crate) fn cancelled(reason: impl Into<String>) -> Self {
Self::Cancelled {
reason: reason.into(),
}
}
pub(crate) fn invalid_input(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::InvalidInput { reason })
}
pub(crate) fn internal(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::Internal { reason })
}
pub(crate) fn code(&self) -> &'static str {
match self {
Self::Initialization { .. } => "initialization",
Self::Ticket { .. } => "invalid_ticket",
Self::Filesystem { .. } => "filesystem",
Self::FilesystemPermission { .. } => "filesystem_permission_denied",
Self::DestinationExists { .. } => "destination_exists",
Self::StorageFull { .. } => "storage_full",
Self::Network { .. } => "network",
Self::Transfer { .. } => "transfer",
Self::Permission { .. } => "permission_denied",
Self::Repository { .. } => "repository",
Self::Cancelled { .. } => "cancelled",
Self::InvalidInput { .. } => "invalid_input",
Self::Internal { .. } => "internal",
}
}
pub(crate) fn reason(&self) -> &str {
match self {
Self::Initialization { reason }
| Self::Ticket { reason }
| Self::Filesystem { reason }
| Self::FilesystemPermission { reason }
| Self::DestinationExists { reason }
| Self::StorageFull { reason }
| Self::Network { reason }
| Self::Transfer { reason }
| Self::Permission { reason }
| Self::Repository { reason }
| Self::Cancelled { reason }
| Self::InvalidInput { reason }
| Self::Internal { reason } => reason,
}
}
fn classify(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
let reason = error.to_string();
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
return existing.with_reason(reason);
}
if let Some(io_error) = error
.chain()
.find_map(|cause| cause.downcast_ref::<io::Error>())
{
return match io_error.kind() {
io::ErrorKind::AlreadyExists => Self::DestinationExists { reason },
io::ErrorKind::PermissionDenied => Self::FilesystemPermission { reason },
io::ErrorKind::StorageFull => Self::StorageFull { reason },
_ => Self::Filesystem { reason },
};
}
if error
.chain()
.any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
{
return Self::Repository { reason };
}
fallback(reason)
}
fn from_error(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
let reason = error.to_string();
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
existing.with_reason(reason)
} else {
fallback(reason)
}
}
fn with_reason(&self, reason: String) -> Self {
match self {
Self::Initialization { .. } => Self::Initialization { reason },
Self::Ticket { .. } => Self::Ticket { reason },
Self::Filesystem { .. } => Self::Filesystem { reason },
Self::FilesystemPermission { .. } => Self::FilesystemPermission { reason },
Self::DestinationExists { .. } => Self::DestinationExists { reason },
Self::StorageFull { .. } => Self::StorageFull { reason },
Self::Network { .. } => Self::Network { reason },
Self::Transfer { .. } => Self::Transfer { reason },
Self::Permission { .. } => Self::Permission { reason },
Self::Repository { .. } => Self::Repository { reason },
Self::Cancelled { .. } => Self::Cancelled { reason },
Self::InvalidInput { .. } => Self::InvalidInput { reason },
Self::Internal { .. } => Self::Internal { reason },
} }
} }
} }
impl From<anyhow::Error> for VnidropError { impl From<anyhow::Error> for VnidropError {
fn from(error: anyhow::Error) -> Self { fn from(error: anyhow::Error) -> Self {
Self::Internal { Self::classify(error, |reason| Self::Internal { reason })
reason: error.to_string(),
}
} }
} }
impl From<io::Error> for VnidropError { impl From<io::Error> for VnidropError {
fn from(error: io::Error) -> Self { fn from(error: io::Error) -> Self {
Self::Filesystem { Self::filesystem(error)
reason: error.to_string(),
}
} }
} }

View File

@@ -69,7 +69,11 @@ impl AtomicOutputFile {
} }
cleanup_stale_temporary_files(parent, STALE_PART_AGE)?; cleanup_stale_temporary_files(parent, STALE_PART_AGE)?;
if std::fs::symlink_metadata(&target).is_ok() { if std::fs::symlink_metadata(&target).is_ok() {
anyhow::bail!("destination already exists: {}", target.display()); return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("destination already exists: {}", target.display()),
)
.into());
} }
let final_name = target let final_name = target
@@ -109,6 +113,10 @@ impl AtomicOutputFile {
self.committed = true; self.committed = true;
Ok(()) Ok(())
} }
pub(crate) fn target(&self) -> &Path {
&self.target
}
} }
/// Publish a fully written temporary file as the final destination without /// Publish a fully written temporary file as the final destination without

View File

@@ -14,7 +14,8 @@ mod transfer_state;
mod util; mod util;
pub use api::{ pub use api::{
default_core_limits, CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, default_core_limits, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, PublishedOutput,
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
TicketInspection, TransferAccessMode, TransferMetadata, TicketInspection, TransferAccessMode, TransferMetadata,
}; };

View File

@@ -15,12 +15,12 @@ use uuid::Uuid;
use crate::{ use crate::{
access_policy::mode_from_storage, access_policy::mode_from_storage,
api::{CoreEvent, ReceiverRequest, StoredTransfer}, api::{CoreEvent, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, StoredTransfer},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
util::now_ms, util::now_ms,
}; };
const SCHEMA_VERSION: i64 = 4; const SCHEMA_VERSION: i64 = 5;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Repository { pub(crate) struct Repository {
@@ -47,6 +47,7 @@ pub(crate) struct TransferUpsert<'a> {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct PersistedShare { pub(crate) struct PersistedShare {
pub(crate) transfer_id: u64, pub(crate) transfer_id: u64,
pub(crate) local_id: String,
pub(crate) content_hash: String, pub(crate) content_hash: String,
pub(crate) access_mode: String, pub(crate) access_mode: String,
} }
@@ -58,6 +59,15 @@ pub(crate) struct RecoveredTransfer {
pub(crate) previous_status: TransferStatus, pub(crate) previous_status: TransferStatus,
} }
pub(crate) struct ReceivedArtifactInsert<'a> {
pub(crate) transfer_local_id: &'a str,
pub(crate) protocol_transfer_id: u64,
pub(crate) relative_path: &'a str,
pub(crate) locator_kind: ReceivedLocatorKind,
pub(crate) locator: &'a str,
pub(crate) logical_size: u64,
}
pub(crate) struct ReceiverRequestInsert<'a> { pub(crate) struct ReceiverRequestInsert<'a> {
pub(crate) id: &'a str, pub(crate) id: &'a str,
pub(crate) transfer_id: u64, pub(crate) transfer_id: u64,
@@ -169,6 +179,24 @@ impl Repository {
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS received_artifacts (
id TEXT PRIMARY KEY,
transfer_local_id TEXT NOT NULL,
protocol_transfer_id INTEGER NOT NULL,
relative_path TEXT NOT NULL,
locator_kind TEXT NOT NULL,
locator TEXT NOT NULL,
logical_size INTEGER NOT NULL,
published_at INTEGER NOT NULL,
UNIQUE(transfer_local_id, relative_path)
);
"#,
)
.execute(&self.pool)
.await?;
sqlx::query( sqlx::query(
r#" r#"
CREATE TABLE IF NOT EXISTS transfer_events ( CREATE TABLE IF NOT EXISTS transfer_events (
@@ -330,6 +358,71 @@ impl Repository {
Ok(()) Ok(())
} }
pub(crate) async fn transfer_local_id(&self, transfer_id: u64) -> Result<String> {
let row = sqlx::query("SELECT local_id FROM transfers WHERE transfer_id = ?1")
.bind(to_db_id(transfer_id)?)
.fetch_one(&self.pool)
.await?;
Ok(row.get(0))
}
pub(crate) async fn record_received_artifact(
&self,
artifact: ReceivedArtifactInsert<'_>,
) -> Result<()> {
sqlx::query(
r#"
INSERT INTO received_artifacts (
id, transfer_local_id, protocol_transfer_id, relative_path,
locator_kind, locator, logical_size, published_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(transfer_local_id, relative_path) DO UPDATE SET
locator_kind = excluded.locator_kind,
locator = excluded.locator,
logical_size = excluded.logical_size,
published_at = excluded.published_at
"#,
)
.bind(Uuid::new_v4().to_string())
.bind(artifact.transfer_local_id)
.bind(to_db_id(artifact.protocol_transfer_id)?)
.bind(artifact.relative_path)
.bind(locator_kind_to_storage(&artifact.locator_kind))
.bind(artifact.locator)
.bind(to_db_id(artifact.logical_size)?)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub(crate) async fn list_received_artifacts(&self) -> Result<Vec<ReceivedArtifact>> {
let rows = sqlx::query(
r#"
SELECT id, transfer_local_id, protocol_transfer_id, relative_path,
locator_kind, locator, logical_size, published_at
FROM received_artifacts
ORDER BY published_at DESC
"#,
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(ReceivedArtifact {
id: row.get("id"),
transfer_local_id: row.get("transfer_local_id"),
protocol_transfer_id: row.get::<i64, _>("protocol_transfer_id") as u64,
relative_path: row.get("relative_path"),
locator_kind: locator_kind_from_storage(&row.get::<String, _>("locator_kind"))?,
locator: row.get("locator"),
logical_size: row.get::<i64, _>("logical_size") as u64,
published_at: row.get("published_at"),
})
})
.collect()
}
pub(crate) async fn complete_share_import(&self, transfer: TransferUpsert<'_>) -> Result<()> { pub(crate) async fn complete_share_import(&self, transfer: TransferUpsert<'_>) -> Result<()> {
self.maybe_fail_write()?; self.maybe_fail_write()?;
if transfer.direction != TransferDirection::Send if transfer.direction != TransferDirection::Send
@@ -508,7 +601,7 @@ impl Repository {
pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> { pub(crate) async fn list_active_shares(&self) -> Result<Vec<PersistedShare>> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
SELECT transfer_id, content_hash, access_mode SELECT transfer_id, local_id, content_hash, access_mode
FROM transfers FROM transfers
WHERE direction = 'send' WHERE direction = 'send'
AND status = 'sharing' AND status = 'sharing'
@@ -521,8 +614,9 @@ impl Repository {
.into_iter() .into_iter()
.map(|row| PersistedShare { .map(|row| PersistedShare {
transfer_id: row.get::<i64, _>(0) as u64, transfer_id: row.get::<i64, _>(0) as u64,
content_hash: row.get::<String, _>(1), local_id: row.get::<String, _>(1),
access_mode: row.get::<String, _>(2), content_hash: row.get::<String, _>(2),
access_mode: row.get::<String, _>(3),
}) })
.collect()) .collect())
} }
@@ -870,6 +964,23 @@ fn to_db_id(value: u64) -> Result<i64> {
i64::try_from(value).context("transfer id exceeds SQLite signed integer range") i64::try_from(value).context("transfer id exceeds SQLite signed integer range")
} }
fn locator_kind_to_storage(kind: &ReceivedLocatorKind) -> &'static str {
match kind {
ReceivedLocatorKind::FilesystemPath => "filesystem_path",
ReceivedLocatorKind::AndroidMediaStore => "android_media_store",
ReceivedLocatorKind::AndroidDocument => "android_document",
}
}
fn locator_kind_from_storage(value: &str) -> Result<ReceivedLocatorKind> {
match value {
"filesystem_path" => Ok(ReceivedLocatorKind::FilesystemPath),
"android_media_store" => Ok(ReceivedLocatorKind::AndroidMediaStore),
"android_document" => Ok(ReceivedLocatorKind::AndroidDocument),
_ => anyhow::bail!("unknown received artifact locator kind: {value}"),
}
}
fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<StoredTransfer> { fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<StoredTransfer> {
let direction = row.get::<String, _>("direction"); let direction = row.get::<String, _>("direction");
let status = row.get::<String, _>("status"); let status = row.get::<String, _>("status");

View File

@@ -6,9 +6,9 @@ use serde_json::json;
use super::CoreInner; use super::CoreInner;
use crate::{ use crate::{
api::{ api::{
CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, RuntimeStatus, CoreEvent, CoreEventSink, CoreLimits, CoreStorageUsage, ReceiveOutputSink,
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus, ShareMetadataInput,
TransferAccessMode, ShareResult, ShareSource, StoredTransfer, TicketInspection, TransferAccessMode,
}, },
error::VnidropError, error::VnidropError,
filesystem::platform_path, filesystem::platform_path,
@@ -126,6 +126,24 @@ impl VnidropCore {
.map_err(VnidropError::transfer) .map_err(VnidropError::transfer)
} }
pub fn receive_with_output_sink_v2(
&self,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
receiver_name: Option<String>,
) -> Result<(), VnidropError> {
if let Err(error) = parse_transfer_ticket_with_limits(&ticket, &self.inner.limits)
.context("failed to parse transfer ticket")
{
return Err(VnidropError::ticket(error));
}
self.block_on(
self.inner
.receive_with_output_sink_v2(ticket, output_sink, receiver_name),
)
.map_err(VnidropError::transfer)
}
pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> { pub fn cancel_transfer(&self, transfer_id: u64) -> Result<(), VnidropError> {
// Fire the oneshot on this thread before entering the runtime so a // Fire the oneshot on this thread before entering the runtime so a
// blocked export write cannot prevent the cancel signal from being // blocked export write cannot prevent the cancel signal from being
@@ -214,6 +232,16 @@ impl VnidropCore {
.map_err(VnidropError::repository) .map_err(VnidropError::repository)
} }
pub fn list_received_artifacts(&self) -> Result<Vec<ReceivedArtifact>, VnidropError> {
self.block_on(self.inner.repository.list_received_artifacts())
.map_err(VnidropError::repository)
}
pub fn storage_usage(&self) -> Result<CoreStorageUsage, VnidropError> {
self.block_on(self.inner.storage_usage())
.map_err(VnidropError::filesystem)
}
pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> { pub fn list_events(&self, transfer_id: Option<u64>) -> Result<Vec<CoreEvent>, VnidropError> {
self.block_on(self.inner.list_events(transfer_id)) self.block_on(self.inner.list_events(transfer_id))
.map_err(VnidropError::repository) .map_err(VnidropError::repository)

View File

@@ -3,7 +3,7 @@ use std::sync::atomic::Ordering;
use anyhow::Result; use anyhow::Result;
use serde_json::json; use serde_json::json;
use super::CoreInner; use super::{share_tag_name, CoreInner};
use crate::{ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::{RuntimeStatus, TransferAccessMode}, api::{RuntimeStatus, TransferAccessMode},
@@ -42,6 +42,7 @@ impl CoreInner {
pub(super) async fn cancel_idle_or_share(&self, transfer_id: u64) -> Result<()> { pub(super) async fn cancel_idle_or_share(&self, transfer_id: u64) -> Result<()> {
let mut active_shares = self.active_shares.lock().await; let mut active_shares = self.active_shares.lock().await;
if active_shares.contains_key(&transfer_id) { if active_shares.contains_key(&transfer_id) {
let local_id = self.repository.transfer_local_id(transfer_id).await?;
self.repository self.repository
.transition_transfer_status( .transition_transfer_status(
transfer_id, transfer_id,
@@ -53,6 +54,7 @@ impl CoreInner {
drop(active_shares); drop(active_shares);
self.unregister_transfer_hashes(transfer_id).await; self.unregister_transfer_hashes(transfer_id).await;
self.access_policy.remove_transfer(transfer_id).await; self.access_policy.remove_transfer(transfer_id).await;
self.store.tags().delete(share_tag_name(&local_id)).await?;
self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({})); self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({}));
return Ok(()); return Ok(());
} }
@@ -105,6 +107,12 @@ impl CoreInner {
self.active_shares.lock().await.remove(&transfer_id); self.active_shares.lock().await.remove(&transfer_id);
self.unregister_transfer_hashes(transfer_id).await; self.unregister_transfer_hashes(transfer_id).await;
self.access_policy.remove_transfer(transfer_id).await; self.access_policy.remove_transfer(transfer_id).await;
if transfer.direction == TransferDirection::Send.as_str() {
self.store
.tags()
.delete(share_tag_name(&transfer.local_id))
.await?;
}
// Events are persisted asynchronously. Drain events emitted before this // Events are persisted asynchronously. Drain events emitted before this
// request so none can be written back after the transfer is deleted. // request so none can be written back after the transfer is deleted.
self.event_hub.flush().await; self.event_hub.flush().await;

View File

@@ -12,6 +12,7 @@ mod lifecycle;
mod provider; mod provider;
mod receive; mod receive;
mod share; mod share;
mod storage;
pub use facade::VnidropCore; pub use facade::VnidropCore;
@@ -20,15 +21,19 @@ use std::{
path::PathBuf, path::PathBuf,
str::FromStr, str::FromStr,
sync::{atomic::AtomicBool, Arc}, sync::{atomic::AtomicBool, Arc},
time::Duration,
}; };
use anyhow::Result; use anyhow::Result;
use futures_lite::StreamExt as _;
use iroh::{endpoint::presets, protocol::Router, Endpoint}; use iroh::{endpoint::presets, protocol::Router, Endpoint};
use iroh_blobs::{ use iroh_blobs::{
api::TempTag,
format::collection::Collection, format::collection::Collection,
provider::events::{EventMask, EventSender}, provider::events::{EventMask, EventSender},
store::fs::FsStore, store::{
fs::{options::Options as FsStoreOptions, FsStore},
GcConfig,
},
BlobsProtocol, Hash, BlobsProtocol, Hash,
}; };
use serde_json::json; use serde_json::json;
@@ -52,6 +57,7 @@ use crate::{
/// Owns the Iroh endpoint, blob store, transfer history, and byte streaming. /// Owns the Iroh endpoint, blob store, transfer history, and byte streaming.
/// Kotlin owns app lifecycle and platform file picking. /// Kotlin owns app lifecycle and platform file picking.
pub(super) struct CoreInner { pub(super) struct CoreInner {
pub(super) app_data_dir: PathBuf,
pub(super) endpoint: Endpoint, pub(super) endpoint: Endpoint,
pub(super) router: Router, pub(super) router: Router,
pub(super) store: FsStore, pub(super) store: FsStore,
@@ -64,10 +70,8 @@ pub(super) struct CoreInner {
/// Sync mutex so cancel can remove + signal without awaiting (and without /// Sync mutex so cancel can remove + signal without awaiting (and without
/// holding a Tokio lock across repository I/O). /// holding a Tokio lock across repository I/O).
pub(super) active_transfers: std::sync::Mutex<HashMap<u64, ActiveTransfer>>, pub(super) active_transfers: std::sync::Mutex<HashMap<u64, ActiveTransfer>>,
// Newly imported shares retain a TempTag for the lifetime of this process. // Active shares are protected by persistent Iroh tags.
// Restored shares have no in-memory tag, but remain tracked so they can be pub(super) active_shares: TokioMutex<HashMap<u64, ()>>,
// counted and explicitly revoked after a restart.
pub(super) active_shares: TokioMutex<HashMap<u64, Option<TempTag>>>,
/// Content hash → active share transfer ids (root and collection members). /// Content hash → active share transfer ids (root and collection members).
/// Multiple transfers can share the same content-addressed hash. /// Multiple transfers can share the same content-addressed hash.
pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>, pub(super) hash_to_transfer: TokioMutex<HashMap<String, HashSet<u64>>>,
@@ -92,7 +96,12 @@ impl CoreInner {
let secret_key = load_or_create_secret(&app_data_dir).await?; let secret_key = load_or_create_secret(&app_data_dir).await?;
let repository = Repository::open(&app_data_dir).await?; let repository = Repository::open(&app_data_dir).await?;
let store_root = app_data_dir.join("blobs"); let store_root = app_data_dir.join("blobs");
let store = FsStore::load(&store_root).await?; let mut store_options = FsStoreOptions::new(&store_root);
store_options.gc = Some(GcConfig {
interval: Duration::from_secs(30 * 60),
add_protected: None,
});
let store = FsStore::load_with_opts(store_root.join("blobs.db"), store_options).await?;
let endpoint = Endpoint::builder(presets::N0) let endpoint = Endpoint::builder(presets::N0)
.secret_key(secret_key) .secret_key(secret_key)
.bind() .bind()
@@ -135,6 +144,7 @@ impl CoreInner {
// Register root + every collection member so child gets stay under ACL. // Register root + every collection member so child gets stay under ACL.
let mut restored_hashes: HashMap<String, HashSet<u64>> = HashMap::new(); let mut restored_hashes: HashMap<String, HashSet<u64>> = HashMap::new();
let mut restored_active_shares = HashMap::new(); let mut restored_active_shares = HashMap::new();
let mut active_tag_names = HashSet::new();
for share in repository.list_active_shares().await? { for share in repository.list_active_shares().await? {
let transfer_id = share.transfer_id; let transfer_id = share.transfer_id;
let Ok(root_hash) = Hash::from_str(&share.content_hash) else { let Ok(root_hash) = Hash::from_str(&share.content_hash) else {
@@ -176,6 +186,12 @@ impl CoreInner {
); );
continue; continue;
}; };
let tag_name = share_tag_name(&share.local_id);
store
.tags()
.set(&tag_name, (root_hash, iroh_blobs::BlobFormat::HashSeq))
.await?;
active_tag_names.insert(tag_name);
restored_hashes restored_hashes
.entry(root_hash.to_string()) .entry(root_hash.to_string())
.or_default() .or_default()
@@ -186,11 +202,19 @@ impl CoreInner {
.or_default() .or_default()
.insert(transfer_id); .insert(transfer_id);
} }
restored_active_shares.insert(transfer_id, None); restored_active_shares.insert(transfer_id, ());
access_policy access_policy
.set_mode(transfer_id, mode_from_storage(&share.access_mode)) .set_mode(transfer_id, mode_from_storage(&share.access_mode))
.await; .await;
} }
let mut share_tags = store.tags().list_prefix("vnidrop/share/").await?;
while let Some(tag) = share_tags.next().await {
let tag = tag?;
let name = String::from_utf8_lossy(tag.name.as_ref()).to_string();
if !active_tag_names.contains(&name) {
store.tags().delete(name).await?;
}
}
let approval = ApprovalService::new( let approval = ApprovalService::new(
repository.clone(), repository.clone(),
event_hub.clone(), event_hub.clone(),
@@ -205,6 +229,7 @@ impl CoreInner {
.spawn(); .spawn();
let inner = Arc::new(Self { let inner = Arc::new(Self {
app_data_dir,
endpoint, endpoint,
router, router,
store, store,
@@ -277,3 +302,7 @@ impl CoreInner {
.await .await
} }
} }
pub(super) fn share_tag_name(local_id: &str) -> String {
format!("vnidrop/share/{local_id}")
}

View File

@@ -17,13 +17,17 @@ use tokio::sync::oneshot;
use super::{ActiveTransfer, CoreInner}; use super::{ActiveTransfer, CoreInner};
use crate::{ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::{ReceiveOutputSink, TransferAccessMode, TransferMetadata}, api::{
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, TransferAccessMode,
TransferMetadata,
},
error::VnidropError,
filesystem::{ filesystem::{
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer, validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
AtomicOutputFile, AtomicOutputFile,
}, },
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService}, handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, HandshakeService},
repository::TransferUpsert, repository::{ReceivedArtifactInsert, TransferUpsert},
ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket}, ticket::{parse_transfer_ticket_with_limits, ParsedTransferTicket},
transfer_state::{TransferDirection, TransferStatus}, transfer_state::{TransferDirection, TransferStatus},
}; };
@@ -31,6 +35,13 @@ use crate::{
pub(super) enum ReceiveTarget { pub(super) enum ReceiveTarget {
Directory(PathBuf), Directory(PathBuf),
OutputSink(Arc<dyn ReceiveOutputSink>), OutputSink(Arc<dyn ReceiveOutputSink>),
OutputSinkV2(Arc<dyn ReceiveOutputSinkV2>),
}
#[derive(Clone, Copy)]
struct ReceivedTransfer<'a> {
protocol_id: u64,
local_id: &'a str,
} }
pub(super) struct OutputSinkFile<'a> { pub(super) struct OutputSinkFile<'a> {
@@ -42,7 +53,7 @@ pub(super) struct OutputSinkFile<'a> {
impl<'a> OutputSinkFile<'a> { impl<'a> OutputSinkFile<'a> {
fn start(sink: &'a dyn ReceiveOutputSink, relative_path: String) -> Result<Self> { fn start(sink: &'a dyn ReceiveOutputSink, relative_path: String) -> Result<Self> {
sink.start_file(relative_path.clone()) sink.start_file(relative_path.clone())
.map_err(|error| anyhow::anyhow!(error.to_string()))?; .map_err(anyhow::Error::new)?;
Ok(Self { Ok(Self {
sink, sink,
relative_path, relative_path,
@@ -53,7 +64,7 @@ impl<'a> OutputSinkFile<'a> {
fn write(&self, bytes: Vec<u8>) -> Result<()> { fn write(&self, bytes: Vec<u8>) -> Result<()> {
self.sink self.sink
.write_chunk(self.relative_path.clone(), bytes) .write_chunk(self.relative_path.clone(), bytes)
.map_err(|error| anyhow::anyhow!(error.to_string())) .map_err(anyhow::Error::new)
} }
fn finish(mut self) -> Result<()> { fn finish(mut self) -> Result<()> {
@@ -63,7 +74,7 @@ impl<'a> OutputSinkFile<'a> {
self.terminal = true; self.terminal = true;
self.sink self.sink
.finish_file(self.relative_path.clone()) .finish_file(self.relative_path.clone())
.map_err(|error| anyhow::anyhow!(error.to_string())) .map_err(anyhow::Error::new)
} }
} }
@@ -85,6 +96,51 @@ impl Drop for OutputSinkFile<'_> {
} }
} }
pub(super) struct OutputSinkFileV2<'a> {
sink: &'a dyn ReceiveOutputSinkV2,
relative_path: String,
terminal: bool,
}
impl<'a> OutputSinkFileV2<'a> {
fn start(sink: &'a dyn ReceiveOutputSinkV2, relative_path: String) -> Result<Self> {
sink.start_file(relative_path.clone())
.map_err(anyhow::Error::new)?;
Ok(Self {
sink,
relative_path,
terminal: false,
})
}
fn write(&self, bytes: Vec<u8>) -> Result<()> {
self.sink
.write_chunk(self.relative_path.clone(), bytes)
.map_err(anyhow::Error::new)
}
fn finish(mut self) -> Result<crate::api::PublishedOutput> {
self.terminal = true;
self.sink
.finish_file(self.relative_path.clone())
.map_err(anyhow::Error::new)
}
}
impl Drop for OutputSinkFileV2<'_> {
fn drop(&mut self) {
if !self.terminal {
self.terminal = true;
if let Err(error) = self.sink.abort_file(
self.relative_path.clone(),
"transfer interrupted before file completion".to_string(),
) {
tracing::warn!(%error, relative_path = %self.relative_path, "failed to abort receive output sink file");
}
}
}
}
impl CoreInner { impl CoreInner {
pub(super) async fn receive( pub(super) async fn receive(
self: &Arc<Self>, self: &Arc<Self>,
@@ -110,6 +166,20 @@ impl CoreInner {
.await .await
} }
pub(super) async fn receive_with_output_sink_v2(
self: &Arc<Self>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
receiver_name: Option<String>,
) -> Result<()> {
self.receive_to_target(
ticket,
ReceiveTarget::OutputSinkV2(output_sink),
receiver_name,
)
.await
}
pub(super) async fn receive_to_target( pub(super) async fn receive_to_target(
self: &Arc<Self>, self: &Arc<Self>,
ticket: String, ticket: String,
@@ -120,7 +190,8 @@ impl CoreInner {
.transfer_slots .transfer_slots
.acquire() .acquire()
.await .await
.context("transfer limiter is closed")?; .context("transfer limiter is closed")
.map_err(VnidropError::internal)?;
let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits) let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
.context("failed to parse transfer ticket") .context("failed to parse transfer ticket")
{ {
@@ -136,7 +207,8 @@ impl CoreInner {
}; };
let transfer_id = parsed.metadata.transfer_id; let transfer_id = parsed.metadata.transfer_id;
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref())
.await?; .await
.map_err(VnidropError::repository)?;
// Cancellation is cooperative: it stops our receive future and marks // Cancellation is cooperative: it stops our receive future and marks
// local state while lower-level Iroh work unwinds naturally. // local state while lower-level Iroh work unwinds naturally.
let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
@@ -152,8 +224,10 @@ impl CoreInner {
); );
let (result, cancelled) = tokio::select! { let (result, cancelled) = tokio::select! {
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => (result, false), result = self.receive_inner(transfer_id, parsed, target, receiver_name) => {
_ = &mut shutdown_rx => (Err(anyhow::anyhow!("transfer cancelled")), true), (result.map_err(VnidropError::transfer), false)
},
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
}; };
self.active_transfers self.active_transfers
@@ -169,7 +243,7 @@ impl CoreInner {
"receive", "receive",
"error", "error",
"failed", "failed",
json!({ "reason": error.to_string() }), json!({ "code": error.code(), "reason": error.reason() }),
); );
let _ = self let _ = self
.repository .repository
@@ -181,7 +255,7 @@ impl CoreInner {
.await; .await;
} }
} }
result result.map_err(anyhow::Error::new)
} }
pub(super) async fn receive_inner( pub(super) async fn receive_inner(
@@ -192,7 +266,9 @@ impl CoreInner {
receiver_name: Option<String>, receiver_name: Option<String>,
) -> Result<()> { ) -> Result<()> {
if let ReceiveTarget::Directory(output_dir) = &target { if let ReceiveTarget::Directory(output_dir) = &target {
tokio::fs::create_dir_all(output_dir).await?; tokio::fs::create_dir_all(output_dir)
.await
.map_err(VnidropError::filesystem)?;
} }
let sender_addr = parsed.blob_ticket.addr().clone(); let sender_addr = parsed.blob_ticket.addr().clone();
@@ -209,14 +285,16 @@ impl CoreInner {
let connection = self let connection = self
.endpoint .endpoint
.connect(sender_addr.clone(), iroh_blobs::ALPN) .connect(sender_addr.clone(), iroh_blobs::ALPN)
.await?; .await
.map_err(VnidropError::network)?;
self.emit_transfer(transfer_id, "receive", "network", "connected", json!({})); self.emit_transfer(transfer_id, "receive", "network", "connected", json!({}));
let hash_and_format = parsed.blob_ticket.hash_and_format(); let hash_and_format = parsed.blob_ticket.hash_and_format();
let (_hash_seq, sizes) = let (_hash_seq, sizes) =
get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None) get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None)
.await .await
.context("failed to get file sizes")?; .context("failed to get file sizes")
.map_err(VnidropError::network)?;
let total_size = sizes let total_size = sizes
.iter() .iter()
.try_fold(0u64, |total, size| total.checked_add(*size)) .try_fold(0u64, |total, size| total.checked_add(*size))
@@ -242,9 +320,15 @@ impl CoreInner {
json!({ "total_files": total_files, "total_size": total_size }), json!({ "total_files": total_files, "total_size": total_size }),
); );
// Protect both partial download state and the completed collection until
// every output has been published and recorded.
let download_tag = self.store.tags().temp_tag(hash_and_format).await?;
let get = self.store.remote().fetch(connection, hash_and_format); let get = self.store.remote().fetch(connection, hash_and_format);
let mut stream = get.stream(); let mut stream = get.stream();
while let Some(item) = stream.next().await { loop {
let Some(item) = stream.next().await else {
anyhow::bail!("download ended without completion");
};
match item { match item {
GetProgressItem::Progress(downloaded) => { GetProgressItem::Progress(downloaded) => {
self.emit_transfer( self.emit_transfer(
@@ -256,7 +340,11 @@ impl CoreInner {
); );
} }
GetProgressItem::Done(_) => break, GetProgressItem::Done(_) => break,
GetProgressItem::Error(error) => anyhow::bail!("download failed: {error}"), GetProgressItem::Error(error) => {
return Err(
VnidropError::network(anyhow::anyhow!("download failed: {error}")).into(),
);
}
} }
} }
@@ -269,7 +357,9 @@ impl CoreInner {
TransferStatus::Receiving, TransferStatus::Receiving,
TransferStatus::Done, TransferStatus::Done,
) )
.await?; .await
.map_err(VnidropError::repository)?;
drop(download_tag);
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
let sender_transfer_id = delivery_receipt.transfer_id; let sender_transfer_id = delivery_receipt.transfer_id;
let client = HandshakeService::client(self.endpoint.clone(), sender_addr); let client = HandshakeService::client(self.endpoint.clone(), sender_addr);
@@ -319,7 +409,8 @@ impl CoreInner {
total_size: parsed.metadata.total_size, total_size: parsed.metadata.total_size,
access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired),
}) })
.await?; .await
.map_err(VnidropError::repository)?;
let metadata_json = let metadata_json =
serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null); serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null);
self.emit_transfer( self.emit_transfer(
@@ -357,8 +448,9 @@ impl CoreInner {
match client match client
.request_transfer(metadata, receiver_name) .request_transfer(metadata, receiver_name)
.await .await
.map_err(|error| anyhow::anyhow!("handshake request failed: {error}"))? .map_err(|error| {
{ VnidropError::network(anyhow::anyhow!("handshake request failed: {error}"))
})? {
HandshakeResponse::Approved { HandshakeResponse::Approved {
request_id, request_id,
token, token,
@@ -380,9 +472,10 @@ impl CoreInner {
token, token,
}) })
} }
HandshakeResponse::Denied { reason } => { HandshakeResponse::Denied { reason } => Err(VnidropError::permission(anyhow::anyhow!(
anyhow::bail!("transfer request was denied by sender: {reason}") "transfer request was denied by sender: {reason}"
} ))
.into()),
} }
} }
@@ -393,11 +486,20 @@ impl CoreInner {
target: ReceiveTarget, target: ReceiveTarget,
collection: Collection, collection: Collection,
) -> Result<()> { ) -> Result<()> {
let transfer_local_id = self
.repository
.transfer_local_id(transfer_id)
.await
.map_err(VnidropError::repository)?;
let received_transfer = ReceivedTransfer {
protocol_id: transfer_id,
local_id: &transfer_local_id,
};
for (i, (name, hash)) in collection.iter().enumerate() { for (i, (name, hash)) in collection.iter().enumerate() {
match &target { match &target {
ReceiveTarget::Directory(output_dir) => { ReceiveTarget::Directory(output_dir) => {
self.export_blob_to_directory( self.export_blob_to_directory(
transfer_id, received_transfer,
total_files, total_files,
i as u64, i as u64,
output_dir, output_dir,
@@ -417,14 +519,25 @@ impl CoreInner {
) )
.await?; .await?;
} }
ReceiveTarget::OutputSinkV2(output_sink) => {
self.export_blob_to_sink_v2(
received_transfer,
total_files,
i as u64,
output_sink.as_ref(),
name.as_ref(),
*hash,
)
.await?;
}
} }
} }
Ok(()) Ok(())
} }
pub(super) async fn export_blob_to_directory( async fn export_blob_to_directory(
&self, &self,
transfer_id: u64, transfer: ReceivedTransfer<'_>,
total_files: u64, total_files: u64,
current_file_index: u64, current_file_index: u64,
output_dir: &Path, output_dir: &Path,
@@ -437,7 +550,8 @@ impl CoreInner {
self.limits.max_path_bytes self.limits.max_path_bytes
); );
} }
let (pending_file, writer) = AtomicOutputFile::create(output_dir, relative_path)?; let (pending_file, writer) = AtomicOutputFile::create(output_dir, relative_path)
.map_err(VnidropError::filesystem)?;
let (tx, rx) = async_channel::bounded::<io::Result<Option<Bytes>>>(2); let (tx, rx) = async_channel::bounded::<io::Result<Option<Bytes>>>(2);
let writer_task = std::thread::spawn(move || write_stream_to_blocking_writer(writer, rx)); let writer_task = std::thread::spawn(move || write_stream_to_blocking_writer(writer, rx));
let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream(); let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream();
@@ -458,7 +572,7 @@ impl CoreInner {
.await .await
.map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?;
self.emit_transfer( self.emit_transfer(
transfer_id, transfer.protocol_id,
"receive", "receive",
"export", "export",
"progress", "progress",
@@ -480,9 +594,27 @@ impl CoreInner {
tx.send(Ok(None)) tx.send(Ok(None))
.await .await
.map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?;
let writer = wait_for_writer(writer_task).await??; let writer = wait_for_writer(writer_task)
tokio::task::spawn_blocking(move || writer.sync_all()).await??; .await
pending_file.commit()?; .map_err(VnidropError::internal)?
.map_err(VnidropError::filesystem)?;
tokio::task::spawn_blocking(move || writer.sync_all())
.await
.map_err(VnidropError::internal)?
.map_err(VnidropError::filesystem)?;
let locator = pending_file.target().to_string_lossy().to_string();
pending_file.commit().map_err(VnidropError::filesystem)?;
self.repository
.record_received_artifact(ReceivedArtifactInsert {
transfer_local_id: transfer.local_id,
protocol_transfer_id: transfer.protocol_id,
relative_path,
locator_kind: ReceivedLocatorKind::FilesystemPath,
locator: &locator,
logical_size: exported,
})
.await
.map_err(VnidropError::repository)?;
Ok(()) Ok(())
} }
@@ -545,4 +677,72 @@ impl CoreInner {
output_file.finish()?; output_file.finish()?;
Ok(()) Ok(())
} }
async fn export_blob_to_sink_v2(
&self,
transfer: ReceivedTransfer<'_>,
total_files: u64,
current_file_index: u64,
output_sink: &dyn ReceiveOutputSinkV2,
relative_path: &str,
hash: Hash,
) -> Result<()> {
if relative_path.len() as u64 > self.limits.max_path_bytes {
anyhow::bail!(
"output path exceeds {} bytes: {relative_path}",
self.limits.max_path_bytes
);
}
let relative_path = validated_relative_string(relative_path)?;
let output_file = OutputSinkFileV2::start(output_sink, relative_path.clone())?;
let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream();
let mut file_size = 0;
let mut exported = 0;
while let Some(item) = stream.next().await {
match item {
ExportRangesItem::Size(size) => file_size = size,
ExportRangesItem::Data(leaf) => {
if leaf.offset != exported {
anyhow::bail!(
"export stream for {relative_path} yielded out-of-order data"
);
}
exported += leaf.data.len() as u64;
output_file.write(leaf.data.to_vec())?;
self.emit_transfer(
transfer.protocol_id,
"receive",
"export",
"progress",
json!({
"total_files": total_files,
"current_file_index": current_file_index,
"file_name": relative_path,
"file_size": file_size,
"exported": exported,
}),
);
tokio::task::yield_now().await;
}
ExportRangesItem::Error(error) => {
anyhow::bail!("export failed for {relative_path}: {error}");
}
}
}
let published = output_file.finish()?;
self.repository
.record_received_artifact(ReceivedArtifactInsert {
transfer_local_id: transfer.local_id,
protocol_transfer_id: transfer.protocol_id,
relative_path: &relative_path,
locator_kind: published.locator_kind,
locator: &published.locator,
logical_size: exported,
})
.await
.map_err(VnidropError::repository)?;
Ok(())
}
} }

View File

@@ -12,11 +12,12 @@ use n0_future::BufferedStreamExt;
use serde_json::json; use serde_json::json;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use super::{ActiveTransfer, CoreInner}; use super::{share_tag_name, ActiveTransfer, CoreInner};
use crate::{ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::TransferMetadata, api::TransferMetadata,
api::{ShareMetadataInput, ShareResult, ShareSource}, api::{ShareMetadataInput, ShareResult, ShareSource},
error::VnidropError,
filesystem::{ filesystem::{
collect_import_files_with_limits, default_collection_name, collect_import_files_with_limits, default_collection_name,
read_stream_from_blocking_reader, TransferImport, read_stream_from_blocking_reader, TransferImport,
@@ -37,22 +38,29 @@ impl CoreInner {
.transfer_slots .transfer_slots
.acquire() .acquire()
.await .await
.context("transfer limiter is closed")?; .context("transfer limiter is closed")
.map_err(VnidropError::internal)?;
let transfer_id = metadata.transfer_id; let transfer_id = metadata.transfer_id;
if sources.is_empty() { if sources.is_empty() {
anyhow::bail!("at least one source is required"); return Err(VnidropError::invalid_input(anyhow::anyhow!(
"at least one source is required"
))
.into());
} }
if sources.len() as u64 > self.limits.max_sources { if sources.len() as u64 > self.limits.max_sources {
anyhow::bail!( return Err(VnidropError::invalid_input(anyhow::anyhow!(
"source count {} exceeds limit {}", "source count {} exceeds limit {}",
sources.len(), sources.len(),
self.limits.max_sources self.limits.max_sources
); ))
.into());
} }
self.limits self.limits
.validate_metadata_text("transfer name", metadata.transfer_name.as_deref())?; .validate_metadata_text("transfer name", metadata.transfer_name.as_deref())
.map_err(VnidropError::invalid_input)?;
self.limits self.limits
.validate_metadata_text("sender name", metadata.sender_name.as_deref())?; .validate_metadata_text("sender name", metadata.sender_name.as_deref())
.map_err(VnidropError::invalid_input)?;
self.repository self.repository
.insert_transfer(TransferUpsert { .insert_transfer(TransferUpsert {
transfer_id, transfer_id,
@@ -66,7 +74,8 @@ impl CoreInner {
total_size: 0, total_size: 0,
access_mode: mode_to_storage(&metadata.access_mode), access_mode: mode_to_storage(&metadata.access_mode),
}) })
.await?; .await
.map_err(VnidropError::repository)?;
let (cancel, mut cancelled) = oneshot::channel(); let (cancel, mut cancelled) = oneshot::channel();
self.active_transfers self.active_transfers
.lock() .lock()
@@ -79,8 +88,10 @@ impl CoreInner {
}, },
); );
let (result, was_cancelled) = tokio::select! { let (result, was_cancelled) = tokio::select! {
result = self.share_files_inner(sources, metadata) => (result, false), result = self.share_files_inner(sources, metadata) => {
_ = &mut cancelled => (Err(anyhow::anyhow!("transfer cancelled")), true), (result.map_err(VnidropError::transfer), false)
},
_ = &mut cancelled => (Err(VnidropError::cancelled("transfer cancelled")), true),
}; };
self.active_transfers self.active_transfers
.lock() .lock()
@@ -95,7 +106,7 @@ impl CoreInner {
"send", "send",
"error", "error",
"failed", "failed",
json!({ "reason": error.to_string() }), json!({ "code": error.code(), "reason": error.reason() }),
); );
let _ = self let _ = self
.repository .repository
@@ -107,7 +118,7 @@ impl CoreInner {
.await; .await;
} }
} }
result result.map_err(anyhow::Error::new)
} }
pub(super) async fn share_files_inner( pub(super) async fn share_files_inner(
@@ -142,11 +153,25 @@ impl CoreInner {
.encode() .encode()
.context("failed to encode VniDrop transfer ticket")?; .context("failed to encode VniDrop transfer ticket")?;
let content_hash = import.root_hash.to_string(); let content_hash = import.root_hash.to_string();
let local_id = self
.repository
.transfer_local_id(metadata.transfer_id)
.await
.map_err(VnidropError::repository)?;
let tag_name = share_tag_name(&local_id);
self.store
.tags()
.set(
&tag_name,
(import.root_hash, iroh_blobs::BlobFormat::HashSeq),
)
.await?;
// Persist the completed share before exposing it through the provider. // Persist the completed share before exposing it through the provider.
// The remaining in-memory registrations are infallible and can be // The remaining in-memory registrations are infallible and can be
// reconstructed from SQLite if the process exits immediately after. // reconstructed from SQLite if the process exits immediately after.
self.repository if let Err(error) = self
.repository
.complete_share_import(TransferUpsert { .complete_share_import(TransferUpsert {
transfer_id: metadata.transfer_id, transfer_id: metadata.transfer_id,
peer_id: None, peer_id: None,
@@ -159,7 +184,11 @@ impl CoreInner {
total_size: import.total_size, total_size: import.total_size,
access_mode: mode_to_storage(&access_mode), access_mode: mode_to_storage(&access_mode),
}) })
.await?; .await
{
let _ = self.store.tags().delete(&tag_name).await;
return Err(VnidropError::repository(error).into());
}
// Map root + every collection member so provider ACL cannot fail-open // Map root + every collection member so provider ACL cannot fail-open
// on child blob hashes that are not the collection root. // on child blob hashes that are not the collection root.
self.register_share_hashes( self.register_share_hashes(
@@ -173,7 +202,8 @@ impl CoreInner {
self.active_shares self.active_shares
.lock() .lock()
.await .await
.insert(metadata.transfer_id, Some(import.tag)); .insert(metadata.transfer_id, ());
drop(import.tag);
// Tickets are capabilities: never persist the full string in events. // Tickets are capabilities: never persist the full string in events.
self.emit_transfer( self.emit_transfer(

View File

@@ -0,0 +1,69 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use super::CoreInner;
use crate::api::CoreStorageUsage;
impl CoreInner {
pub(super) async fn storage_usage(&self) -> Result<CoreStorageUsage> {
let app_data_dir = self.app_data_dir.clone();
tokio::task::spawn_blocking(move || scan_storage(&app_data_dir)).await?
}
}
fn scan_storage(app_data_dir: &Path) -> Result<CoreStorageUsage> {
let blob_store_bytes = directory_size(&app_data_dir.join("blobs"))?;
let logs_bytes = directory_size(&app_data_dir.join("logs"))?;
let previews_bytes = directory_size(&app_data_dir.join("ui").join("previews"))?;
let database_bytes = [
"vnidrop.sqlite3",
"vnidrop.sqlite3-wal",
"vnidrop.sqlite3-shm",
]
.into_iter()
.try_fold(0u64, |total, name| {
Ok::<_, std::io::Error>(total.saturating_add(file_size(&app_data_dir.join(name))?))
})?;
let total = directory_size(app_data_dir)?;
let classified = blob_store_bytes
.saturating_add(logs_bytes)
.saturating_add(previews_bytes)
.saturating_add(database_bytes);
Ok(CoreStorageUsage {
blob_store_bytes,
database_bytes,
logs_bytes,
previews_bytes,
other_core_bytes: total.saturating_sub(classified),
})
}
fn directory_size(path: &Path) -> Result<u64> {
if !path.exists() {
return Ok(0);
}
let mut total = 0u64;
let mut pending = vec![PathBuf::from(path)];
while let Some(directory) = pending.pop() {
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let metadata = entry.metadata()?;
if metadata.is_dir() {
pending.push(entry.path());
} else if metadata.is_file() {
total = total.saturating_add(metadata.len());
}
}
}
Ok(total)
}
fn file_size(path: &Path) -> std::io::Result<u64> {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => Ok(metadata.len()),
Ok(_) => Ok(0),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(error) => Err(error),
}
}

View File

@@ -1,5 +1,7 @@
#[path = "tests/access_policy.rs"] #[path = "tests/access_policy.rs"]
mod access_policy_tests; mod access_policy_tests;
#[path = "tests/error.rs"]
mod error_tests;
#[path = "tests/filesystem.rs"] #[path = "tests/filesystem.rs"]
mod filesystem_tests; mod filesystem_tests;
#[path = "tests/handshake.rs"] #[path = "tests/handshake.rs"]

View File

@@ -0,0 +1,54 @@
use std::io;
use crate::VnidropError;
#[test]
fn transfer_boundary_classifies_operating_system_failures() {
let already_exists = VnidropError::transfer(io::Error::new(
io::ErrorKind::AlreadyExists,
"target exists",
));
let permission = VnidropError::transfer(io::Error::new(
io::ErrorKind::PermissionDenied,
"access denied",
));
let storage = VnidropError::transfer(io::Error::new(io::ErrorKind::StorageFull, "disk full"));
let filesystem =
VnidropError::transfer(io::Error::new(io::ErrorKind::NotFound, "folder missing"));
assert!(matches!(
already_exists,
VnidropError::DestinationExists { .. }
));
assert!(matches!(
permission,
VnidropError::FilesystemPermission { .. }
));
assert!(matches!(storage, VnidropError::StorageFull { .. }));
assert!(matches!(filesystem, VnidropError::Filesystem { .. }));
}
#[test]
fn transfer_boundary_preserves_typed_errors_through_context() {
let error = anyhow::Error::new(VnidropError::Network {
reason: "connection reset".to_string(),
})
.context("download failed");
let classified = VnidropError::transfer(error);
assert!(matches!(
classified,
VnidropError::Network { ref reason } if reason == "download failed"
));
assert_eq!(classified.code(), "network");
}
#[test]
fn transfer_boundary_classifies_database_failures() {
let transfer = VnidropError::transfer(sqlx::Error::RowNotFound);
let approval = VnidropError::permission(sqlx::Error::RowNotFound);
assert!(matches!(transfer, VnidropError::Repository { .. }));
assert!(matches!(approval, VnidropError::Repository { .. }));
}

View File

@@ -1,6 +1,6 @@
use crate::{ use crate::{
api::CoreEvent, api::{CoreEvent, ReceivedLocatorKind},
repository::{ReceiverRequestInsert, Repository, TransferUpsert}, repository::{ReceivedArtifactInsert, ReceiverRequestInsert, Repository, TransferUpsert},
transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus}, transfer_state::{ReceiverRequestStatus, TransferDirection, TransferStatus},
}; };
@@ -23,11 +23,47 @@ fn transfer(
} }
} }
#[tokio::test]
async fn received_artifacts_survive_history_deletion() {
let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap();
repository
.start_receive(transfer(
91,
TransferDirection::Receive,
TransferStatus::Receiving,
))
.await
.unwrap();
let local_id = repository.transfer_local_id(91).await.unwrap();
repository
.record_received_artifact(ReceivedArtifactInsert {
transfer_local_id: &local_id,
protocol_transfer_id: 91,
relative_path: "folder/file.txt",
locator_kind: ReceivedLocatorKind::FilesystemPath,
locator: "/tmp/folder/file.txt",
logical_size: 12,
})
.await
.unwrap();
repository
.transition_transfer_status(91, TransferStatus::Receiving, TransferStatus::Done)
.await
.unwrap();
assert_eq!(repository.delete_receive_history().await.unwrap(), 1);
let artifacts = repository.list_received_artifacts().await.unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].transfer_local_id, local_id);
assert_eq!(artifacts[0].logical_size, 12);
}
#[tokio::test] #[tokio::test]
async fn persists_transfers_and_events_across_reopen() { async fn persists_transfers_and_events_across_reopen() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 4); assert_eq!(repository.schema_version().await.unwrap(), 5);
repository repository
.insert_transfer(transfer( .insert_transfer(transfer(
7, 7,
@@ -491,7 +527,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() {
pool.close().await; pool.close().await;
let repository = Repository::open(temp.path()).await.unwrap(); let repository = Repository::open(temp.path()).await.unwrap();
assert_eq!(repository.schema_version().await.unwrap(), 4); assert_eq!(repository.schema_version().await.unwrap(), 5);
let stored = repository.list_transfers().await.unwrap().remove(0); let stored = repository.list_transfers().await.unwrap().remove(0);
assert_eq!(stored.transfer_id, 7); assert_eq!(stored.transfer_id, 7);
assert_eq!(stored.local_id, "legacy-7-send"); assert_eq!(stored.local_id, "legacy-7-send");

View File

@@ -3,6 +3,8 @@ mod support;
use std::sync::{Arc, Condvar, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration; use std::time::Duration;
use futures_lite::StreamExt as _;
use iroh_blobs::store::fs::FsStore;
use support::{share_path, CoreGuard, RecordingSink, TestNode}; use support::{share_path, CoreGuard, RecordingSink, TestNode};
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind, CoreEvent, CoreEventSink, CoreLimits, ShareMetadataInput, ShareSource, SourceKind,
@@ -77,6 +79,39 @@ fn deleting_share_revokes_it_and_removes_persisted_history() {
assert_eq!(restarted.status().active_shares, 0); assert_eq!(restarted.status().active_shares, 0);
} }
#[test]
fn active_share_tag_is_persistent_and_removed_with_transfer() {
let source_dir = tempfile::tempdir().unwrap();
let core_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("tagged.txt");
std::fs::write(&source_path, b"tagged content").unwrap();
let sender = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
let share = share_path(&sender, &source_path, 102, "tagged.txt", false);
drop(sender);
assert_eq!(share_tag_count(core_dir.path()), 1);
let restarted = CoreGuard::start(core_dir.path(), Arc::new(RecordingSink::default()));
restarted.delete_transfer(share.transfer_id).unwrap();
drop(restarted);
assert_eq!(share_tag_count(core_dir.path()), 0);
}
fn share_tag_count(core_dir: &std::path::Path) -> usize {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let store = FsStore::load(core_dir.join("blobs")).await.unwrap();
let mut tags = store.tags().list_prefix("vnidrop/share/").await.unwrap();
let mut count = 0;
while let Some(tag) = tags.next().await {
tag.unwrap();
count += 1;
}
store.shutdown().await.unwrap();
count
})
}
#[test] #[test]
fn persisted_share_is_recovered_and_can_be_stopped_after_restart() { fn persisted_share_is_recovered_and_can_be_stopped_after_restart() {
let source_dir = tempfile::tempdir().unwrap(); let source_dir = tempfile::tempdir().unwrap();

View File

@@ -5,8 +5,58 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use support::{ use support::{
receive_with_sink_response, share_path, wait_for_receiver_request, MemoryOutputSink, TestNode, receive_with_sink_response, receive_with_sink_v2_response, share_path,
wait_for_receiver_request, MemoryOutputSink, TestNode,
}; };
use vnidrop::{PublishedOutput, ReceiveOutputSinkV2, VnidropError};
struct ExistingDestinationSink;
impl ReceiveOutputSinkV2 for ExistingDestinationSink {
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
Err(VnidropError::DestinationExists {
reason: format!("destination already exists: {relative_path}"),
})
}
fn write_chunk(&self, _relative_path: String, _bytes: Vec<u8>) -> Result<(), VnidropError> {
unreachable!("a rejected file must not be written")
}
fn finish_file(&self, _relative_path: String) -> Result<PublishedOutput, VnidropError> {
unreachable!("a rejected file must not be finished")
}
fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> {
unreachable!("start_file failure must not be aborted")
}
}
#[test]
fn versioned_sink_records_published_locator() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("tracked.txt");
std::fs::write(&source_path, b"tracked").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 38, "tracked.txt", false);
let output_sink = Arc::new(MemoryOutputSink::default());
receive_with_sink_v2_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
output_sink,
true,
)
.unwrap();
let artifacts = receiver.core.list_received_artifacts().unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].locator, "content://test/tracked.txt");
assert_eq!(artifacts[0].logical_size, 7);
}
#[test] #[test]
fn exports_nested_files_to_output_sink() { fn exports_nested_files_to_output_sink() {
@@ -62,10 +112,45 @@ fn reports_output_sink_write_failure() {
) )
.unwrap_err(); .unwrap_err();
assert!(error.contains("sink write failed")); assert!(matches!(
error,
VnidropError::Filesystem { ref reason } if reason.contains("sink write failed")
));
assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted")); assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted"));
} }
#[test]
fn preserves_typed_output_sink_failure() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("existing.txt");
std::fs::write(&source_path, b"new content").unwrap();
let sender = TestNode::new();
let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 39, "existing.txt", false);
let error = receive_with_sink_v2_response(
&sender.core,
share.transfer_id,
receiver.core.arc(),
share.ticket,
Arc::new(ExistingDestinationSink),
true,
)
.unwrap_err();
assert!(matches!(error, VnidropError::DestinationExists { .. }));
assert!(receiver
.core
.list_events(Some(39))
.unwrap()
.iter()
.any(|event| {
event.phase == "error"
&& event.kind == "failed"
&& event.data_json.contains("\"code\":\"destination_exists\"")
}));
}
#[test] #[test]
fn cancellation_during_export_aborts_open_sink_file() { fn cancellation_during_export_aborts_open_sink_file() {
// Gate the first write so export is mid-file when cancel runs. A large // Gate the first write so export is mid-file when cancel runs. A large

View File

@@ -14,8 +14,9 @@ use std::{
}; };
use vnidrop::{ use vnidrop::{
CoreEvent, CoreEventSink, CoreLimits, ReceiveOutputSink, ReceiverRequest, ShareMetadataInput, CoreEvent, CoreEventSink, CoreLimits, PublishedOutput, ReceiveOutputSink, ReceiveOutputSinkV2,
ShareResult, ShareSource, SourceKind, TransferAccessMode, VnidropCore, VnidropError, ReceivedLocatorKind, ReceiverRequest, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
TransferAccessMode, VnidropCore, VnidropError,
}; };
#[derive(Default)] #[derive(Default)]
@@ -219,6 +220,28 @@ impl ReceiveOutputSink for MemoryOutputSink {
} }
} }
impl ReceiveOutputSinkV2 for MemoryOutputSink {
fn start_file(&self, relative_path: String) -> Result<(), VnidropError> {
ReceiveOutputSink::start_file(self, relative_path)
}
fn write_chunk(&self, relative_path: String, bytes: Vec<u8>) -> Result<(), VnidropError> {
ReceiveOutputSink::write_chunk(self, relative_path, bytes)
}
fn finish_file(&self, relative_path: String) -> Result<PublishedOutput, VnidropError> {
ReceiveOutputSink::finish_file(self, relative_path.clone())?;
Ok(PublishedOutput {
locator_kind: ReceivedLocatorKind::AndroidDocument,
locator: format!("content://test/{relative_path}"),
})
}
fn abort_file(&self, relative_path: String, reason: String) -> Result<(), VnidropError> {
ReceiveOutputSink::abort_file(self, relative_path, reason)
}
}
pub fn share_path( pub fn share_path(
sender: &VnidropCore, sender: &VnidropCore,
source: &Path, source: &Path,
@@ -269,12 +292,10 @@ pub fn receive_with_response(
ticket: String, ticket: String,
output_dir: &Path, output_dir: &Path,
accepted: bool, accepted: bool,
) -> Result<(), String> { ) -> Result<(), VnidropError> {
let output_dir = output_dir.to_string_lossy().to_string(); let output_dir = output_dir.to_string_lossy().to_string();
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
receiver receiver.receive(ticket, output_dir, Some("receiver".to_string()))
.receive(ticket, output_dir, Some("receiver".to_string()))
.map_err(|error| error.to_string())
}); });
respond_to_pending_request(sender, transfer_id, accepted); respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap() handle.join().unwrap()
@@ -287,11 +308,24 @@ pub fn receive_with_sink_response(
ticket: String, ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>, output_sink: Arc<dyn ReceiveOutputSink>,
accepted: bool, accepted: bool,
) -> Result<(), String> { ) -> Result<(), VnidropError> {
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
receiver receiver.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string()))
.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string())) });
.map_err(|error| error.to_string()) respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap()
}
pub fn receive_with_sink_v2_response(
sender: &VnidropCore,
transfer_id: u64,
receiver: Arc<VnidropCore>,
ticket: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>,
accepted: bool,
) -> Result<(), VnidropError> {
let handle = std::thread::spawn(move || {
receiver.receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string()))
}); });
respond_to_pending_request(sender, transfer_id, accepted); respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap() handle.join().unwrap()

View File

@@ -1,6 +1,7 @@
mod support; mod support;
use support::{receive_with_response, share_path, TestNode}; use support::{receive_with_response, share_path, TestNode};
use vnidrop::VnidropError;
#[test] #[test]
fn transfers_file_between_two_cores() { fn transfers_file_between_two_cores() {
@@ -38,6 +39,20 @@ fn transfers_file_between_two_cores() {
received.peer_id.as_deref(), received.peer_id.as_deref(),
Some(sender.core.status().endpoint_id.as_str()) Some(sender.core.status().endpoint_id.as_str())
); );
let artifacts = receiver.core.list_received_artifacts().unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].relative_path, "hello.txt");
assert_eq!(
artifacts[0].logical_size,
b"hello from vnidrop".len() as u64
);
assert_eq!(
artifacts[0].locator,
output_dir.path().join("hello.txt").to_string_lossy()
);
receiver.core.delete_receive_history().unwrap();
assert_eq!(receiver.core.list_received_artifacts().unwrap(), artifacts);
} }
#[test] #[test]
@@ -84,7 +99,7 @@ fn receive_refuses_to_overwrite_existing_destination() {
let receiver = TestNode::new(); let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 27, "existing.txt", false); let share = share_path(&sender.core, &source_path, 27, "existing.txt", false);
assert!(receive_with_response( let error = receive_with_response(
&sender.core, &sender.core,
share.transfer_id, share.transfer_id,
receiver.core.arc(), receiver.core.arc(),
@@ -92,7 +107,8 @@ fn receive_refuses_to_overwrite_existing_destination() {
output_dir.path(), output_dir.path(),
true, true,
) )
.is_err()); .unwrap_err();
assert!(matches!(error, VnidropError::DestinationExists { .. }));
assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content"); assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content");
assert!(std::fs::read_dir(output_dir.path()) assert!(std::fs::read_dir(output_dir.path())
.unwrap() .unwrap()
@@ -101,4 +117,22 @@ fn receive_refuses_to_overwrite_existing_destination() {
.file_name() .file_name()
.to_string_lossy() .to_string_lossy()
.contains(".part"))); .contains(".part")));
let transfer = receiver
.core
.list_transfers()
.unwrap()
.into_iter()
.find(|transfer| transfer.transfer_id == 27)
.unwrap();
assert_eq!(transfer.status, "failed");
assert!(receiver
.core
.list_events(Some(27))
.unwrap()
.iter()
.any(|event| {
event.phase == "error"
&& event.kind == "failed"
&& event.data_json.contains("\"code\":\"destination_exists\"")
}));
} }

View File

@@ -24,6 +24,8 @@ dependencies {
implementation(projects.shared) implementation(projects.shared)
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.filekit.dialogs)
implementation(libs.jna.platform)
implementation(libs.kotlinx.coroutinesSwing) implementation(libs.kotlinx.coroutinesSwing)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
@@ -48,6 +50,7 @@ compose.desktop {
linux { linux {
packageName = "vnidrop" packageName = "vnidrop"
iconFile.set(project.file("../assets/linux/app-icon.png")) iconFile.set(project.file("../assets/linux/app-icon.png"))
modules("jdk.security.auth")
debMaintainer = "support@sudosy.fr" debMaintainer = "support@sudosy.fr"
appRelease = "1" appRelease = "1"
rpmLicenseType = "Apache-2.0" rpmLicenseType = "Apache-2.0"

View File

@@ -0,0 +1,241 @@
package com.vnidrop.app
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathBuilder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.WindowScope
import androidx.compose.ui.window.WindowState
import com.vnidrop.app.ui.theme.LocalVniDropColors
internal class WindowsChromeConfiguration(
val topInset: Dp = 0.dp,
val contentTopStartRadius: Dp = 0.dp,
val useNativeBackdrop: Boolean = false,
val onDarkThemeChanged: (Boolean) -> Unit = {},
val chrome: (@Composable () -> Unit)? = null,
)
@Composable
internal fun WindowScope.WindowsWindowFrame(
windowState: WindowState,
content: @Composable (WindowsChromeConfiguration) -> Unit,
) {
val controller = remember(window) { WindowsNativeWindowController.install(window) }
DisposableEffect(controller) {
onDispose { controller?.close() }
}
if (controller == null) {
content(WindowsChromeConfiguration())
return
}
if (!controller.usesCustomChrome) {
content(WindowsChromeConfiguration())
return
}
val density = LocalDensity.current
val insets = controller.frameInsets
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = with(density) { insets.left.toDp() },
top = with(density) { insets.top.toDp() },
end = with(density) { insets.right.toDp() },
bottom = with(density) { insets.bottom.toDp() },
),
) {
content(
WindowsChromeConfiguration(
topInset = WindowsTitleBarHeight,
contentTopStartRadius = DesktopContentCornerRadius,
useNativeBackdrop = controller.usesNativeBackdrop,
onDarkThemeChanged = controller::setDarkTheme,
chrome = {
WindowsTitleBar(
controller = controller,
isMaximized = windowState.placement == WindowPlacement.Maximized,
)
},
),
)
}
}
@Composable
private fun WindowsTitleBar(
controller: WindowsNativeWindowController,
isMaximized: Boolean,
) {
val colors = LocalVniDropColors.current
val foreground = colors.foregroundDefault.copy(alpha = if (controller.isWindowActive) 1f else 0.55f)
Box(
modifier = Modifier
.fillMaxWidth()
.height(WindowsTitleBarHeight)
.background(if (controller.usesNativeBackdrop) Color.Transparent else colors.backgroundSurface200)
.onGloballyPositioned { controller.updateCaptionBounds(it.boundsInWindow()) },
) {
BasicText(
text = "VniDrop",
modifier = Modifier.align(Alignment.Center),
style = TextStyle(
color = foreground,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
),
)
if (!controller.usesSystemCaptionButtons) {
Row(modifier = Modifier.align(Alignment.TopEnd)) {
WindowsCaptionButton(
button = WindowsCaptionButton.Minimize,
icon = WindowsMinimizeIcon,
contentDescription = "Minimize window",
controller = controller,
onClick = controller::minimize,
)
WindowsCaptionButton(
button = WindowsCaptionButton.Maximize,
icon = if (isMaximized) WindowsRestoreIcon else WindowsMaximizeIcon,
contentDescription = if (isMaximized) "Restore window" else "Maximize window",
controller = controller,
onClick = controller::toggleMaximize,
)
WindowsCaptionButton(
button = WindowsCaptionButton.Close,
icon = WindowsCloseIcon,
contentDescription = "Close window",
controller = controller,
onClick = { controller.postCloseRequest() },
)
}
}
}
}
@Composable
private fun WindowsCaptionButton(
button: WindowsCaptionButton,
icon: ImageVector,
contentDescription: String,
controller: WindowsNativeWindowController,
onClick: () -> Unit,
) {
val colors = LocalVniDropColors.current
val hovered = controller.hoveredCaptionButton == button
val pressed = controller.pressedCaptionButton == button
val destructive = button == WindowsCaptionButton.Close && (hovered || pressed)
val background = when {
destructive -> colors.destructiveDefault
pressed -> colors.backgroundOverlayHover
hovered -> colors.backgroundOverlayHover
else -> Color.Transparent
}
val foreground = when {
destructive -> Color.White
controller.isWindowActive -> colors.foregroundDefault
else -> colors.foregroundDefault.copy(alpha = 0.55f)
}
Box(
modifier = Modifier
.size(width = WindowsCaptionButtonWidth, height = WindowsCaptionButtonHeight)
.background(background)
.clickable(role = Role.Button, onClick = onClick)
.onGloballyPositioned { coordinates ->
when (button) {
WindowsCaptionButton.Minimize -> controller.updateMinimizeButtonBounds(coordinates.boundsInWindow())
WindowsCaptionButton.Maximize -> controller.updateMaximizeButtonBounds(coordinates.boundsInWindow())
WindowsCaptionButton.Close -> controller.updateCloseButtonBounds(coordinates.boundsInWindow())
}
},
contentAlignment = Alignment.Center,
) {
Image(
painter = rememberVectorPainter(icon),
contentDescription = contentDescription,
colorFilter = ColorFilter.tint(foreground),
modifier = Modifier.size(24.dp),
)
}
}
internal val WindowsTitleBarHeight = WindowsTitleBarHeightDip.dp
private val WindowsCaptionButtonWidth = 48.dp
private val WindowsCaptionButtonHeight = 48.dp
private val WindowsMinimizeIcon = windowsCaptionIcon("Minimize") {
moveTo(5f, 12f)
lineTo(19f, 12f)
}
private val WindowsMaximizeIcon = windowsCaptionIcon("Maximize") {
moveTo(6.5f, 6.5f)
lineTo(17.5f, 6.5f)
lineTo(17.5f, 17.5f)
lineTo(6.5f, 17.5f)
close()
}
private val WindowsRestoreIcon = windowsCaptionIcon("Restore") {
moveTo(8.5f, 8.5f)
lineTo(18f, 8.5f)
lineTo(18f, 18f)
lineTo(8.5f, 18f)
close()
moveTo(6f, 15.5f)
lineTo(6f, 6f)
lineTo(15.5f, 6f)
}
private val WindowsCloseIcon = windowsCaptionIcon("Close") {
moveTo(6.5f, 6.5f)
lineTo(17.5f, 17.5f)
moveTo(17.5f, 6.5f)
lineTo(6.5f, 17.5f)
}
private fun windowsCaptionIcon(name: String, block: PathBuilder.() -> Unit): ImageVector =
ImageVector.Builder(name, 24.dp, 24.dp, 24f, 24f).apply {
path(
fill = SolidColor(Color.Transparent),
stroke = SolidColor(Color.Black),
strokeLineWidth = 1.4f,
strokeLineCap = StrokeCap.Square,
strokeLineJoin = StrokeJoin.Miter,
pathFillType = PathFillType.NonZero,
pathBuilder = block,
)
}.build()

File diff suppressed because it is too large Load Diff

View File

@@ -9,13 +9,13 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.window.WindowDraggableArea import androidx.compose.foundation.window.WindowDraggableArea
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -53,12 +53,15 @@ import com.vnidrop.app.feature.receive.VniDropInvitationExtension
import com.vnidrop.app.feature.receive.decodeInvitationBytes import com.vnidrop.app.feature.receive.decodeInvitationBytes
import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.platform.DesktopAppearanceBridge
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
import io.github.vinceglb.filekit.FileKit
import java.awt.Desktop import java.awt.Desktop
import java.io.File import java.io.File
fun main(args: Array<String>) { fun main(args: Array<String>) {
FileKit.init(appId = "vnidrop")
val externalInvitations = ExternalInvitationController() val externalInvitations = ExternalInvitationController()
val linux = DesktopAppearanceBridge.isLinux() val linux = DesktopAppearanceBridge.isLinux()
val windows = DesktopAppearanceBridge.isWindows()
configureInvitationOpenHandler(externalInvitations) configureInvitationOpenHandler(externalInvitations)
args.asSequence() args.asSequence()
.map(::File) .map(::File)
@@ -73,8 +76,21 @@ fun main(args: Array<String>) {
// Compose keeps edge resizers active for this client-decorated Linux window. // Compose keeps edge resizers active for this client-decorated Linux window.
undecorated = linux, undecorated = linux,
) { ) {
val dependencies = rememberJvmAppDependencies(externalInvitations)
if (windows) {
WindowsWindowFrame(windowState) { chrome ->
App( App(
dependencies = rememberJvmAppDependencies(externalInvitations), dependencies = dependencies,
windowChromeTopInset = chrome.topInset,
windowContentTopStartRadius = chrome.contentTopStartRadius,
useNativeWindowBackdrop = chrome.useNativeBackdrop,
onResolvedDarkThemeChanged = chrome.onDarkThemeChanged,
windowChrome = chrome.chrome,
)
}
} else {
App(
dependencies = dependencies,
windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp,
windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp,
windowChrome = if (linux) { windowChrome = if (linux) {
@@ -94,6 +110,7 @@ fun main(args: Array<String>) {
) )
} }
} }
}
} }
private fun configureInvitationOpenHandler(controller: ExternalInvitationController) { private fun configureInvitationOpenHandler(controller: ExternalInvitationController) {
@@ -114,10 +131,11 @@ private fun ExternalInvitationController.openFile(file: File) {
} }
} }
private val LinuxTitleBarHeight = 40.dp private val LinuxTitleBarHeight = 48.dp
private val LinuxWindowControlWidth = 46.dp private val LinuxWindowControlHitTargetSize = 34.dp
private val LinuxWindowControlsWidth = 138.dp private val LinuxWindowControlVisualSize = 28.dp
private val DesktopContentCornerRadius = 20.dp private val LinuxWindowControlsWidth = 120.dp
internal val DesktopContentCornerRadius = 20.dp
@Composable @Composable
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@@ -156,7 +174,9 @@ private fun WindowScope.LinuxTitleBar(
Row( Row(
modifier = Modifier modifier = Modifier
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.fillMaxHeight(), .padding(end = 10.dp)
.background(colors.backgroundSurface300, RoundedCornerShape(20.dp))
.padding(3.dp),
) { ) {
LinuxWindowControlButton( LinuxWindowControlButton(
icon = LinuxMinimizeIcon, icon = LinuxMinimizeIcon,
@@ -189,17 +209,15 @@ private fun LinuxWindowControlButton(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val hovered by interactionSource.collectIsHoveredAsState() val hovered by interactionSource.collectIsHoveredAsState()
val pressed by interactionSource.collectIsPressedAsState() val pressed by interactionSource.collectIsPressedAsState()
val active = hovered || pressed val visualState = linuxWindowControlVisualState(isClose, hovered, pressed)
val background = when { val background = when (visualState) {
isClose && active -> colors.destructiveDefault LinuxWindowControlVisualState.Default -> Color.Transparent
active -> colors.backgroundOverlayHover LinuxWindowControlVisualState.NeutralActive -> colors.backgroundOverlayHover
else -> Color.Transparent LinuxWindowControlVisualState.DestructiveActive -> colors.destructiveDefault
} }
Box( Box(
modifier = Modifier modifier = Modifier
.width(LinuxWindowControlWidth) .size(LinuxWindowControlHitTargetSize)
.fillMaxHeight()
.background(background)
.hoverable(interactionSource) .hoverable(interactionSource)
.clickable( .clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
@@ -208,14 +226,40 @@ private fun LinuxWindowControlButton(
onClick = onClick, onClick = onClick,
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(LinuxWindowControlVisualSize)
.background(background, CircleShape),
contentAlignment = Alignment.Center,
) { ) {
Image( Image(
painter = rememberVectorPainter(icon), painter = rememberVectorPainter(icon),
contentDescription = contentDescription, contentDescription = contentDescription,
colorFilter = ColorFilter.tint(if (isClose && active) Color.White else colors.foregroundLight), colorFilter = ColorFilter.tint(
modifier = Modifier.size(15.dp), if (visualState == LinuxWindowControlVisualState.DestructiveActive) Color.White
else colors.foregroundLight,
),
modifier = Modifier.size(14.dp),
) )
} }
}
}
internal enum class LinuxWindowControlVisualState {
Default,
NeutralActive,
DestructiveActive,
}
internal fun linuxWindowControlVisualState(
isClose: Boolean,
isHovered: Boolean,
isPressed: Boolean,
): LinuxWindowControlVisualState = when {
isClose && (isHovered || isPressed) -> LinuxWindowControlVisualState.DestructiveActive
isHovered || isPressed -> LinuxWindowControlVisualState.NeutralActive
else -> LinuxWindowControlVisualState.Default
} }
internal fun toggledWindowPlacement(current: WindowPlacement): WindowPlacement = internal fun toggledWindowPlacement(current: WindowPlacement): WindowPlacement =

View File

@@ -1,8 +1,12 @@
package com.vnidrop.app package com.vnidrop.app
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowPlacement
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.jetbrains.skiko.GraphicsApi
class DesktopWindowChromeTest { class DesktopWindowChromeTest {
@Test @Test
@@ -10,4 +14,138 @@ class DesktopWindowChromeTest {
assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating)) assertEquals(WindowPlacement.Maximized, toggledWindowPlacement(WindowPlacement.Floating))
assertEquals(WindowPlacement.Floating, toggledWindowPlacement(WindowPlacement.Maximized)) assertEquals(WindowPlacement.Floating, toggledWindowPlacement(WindowPlacement.Maximized))
} }
@Test
fun closeControlUsesDestructiveStateWhenHoveredOrPressed() {
assertEquals(
LinuxWindowControlVisualState.DestructiveActive,
linuxWindowControlVisualState(isClose = true, isHovered = true, isPressed = false),
)
assertEquals(
LinuxWindowControlVisualState.DestructiveActive,
linuxWindowControlVisualState(isClose = true, isHovered = false, isPressed = true),
)
}
@Test
fun standardControlsKeepNeutralInteractionState() {
assertEquals(
LinuxWindowControlVisualState.NeutralActive,
linuxWindowControlVisualState(isClose = false, isHovered = true, isPressed = false),
)
assertEquals(
LinuxWindowControlVisualState.Default,
linuxWindowControlVisualState(isClose = false, isHovered = false, isPressed = false),
)
}
@Test
fun windowsHitTestingPreservesNativeResizeAndCaptionBehavior() {
val geometry = windowsGeometry()
assertEquals(WindowsHitTestResult.TopLeft, windowsHitTest(2, 2, geometry))
assertEquals(WindowsHitTestResult.Right, windowsHitTest(1_198, 300, geometry))
assertEquals(WindowsHitTestResult.Bottom, windowsHitTest(600, 798, geometry))
assertEquals(WindowsHitTestResult.Caption, windowsHitTest(600, 24, geometry))
assertEquals(WindowsHitTestResult.Client, windowsHitTest(600, 200, geometry))
}
@Test
fun windowsCaptionButtonsReturnNativeHitCodesForSnapLayouts() {
val geometry = windowsGeometry()
assertEquals(WindowsHitTestResult.MinimizeButton, windowsHitTest(1_075, 20, geometry))
assertEquals(WindowsHitTestResult.MaximizeButton, windowsHitTest(1_120, 20, geometry))
assertEquals(WindowsHitTestResult.CloseButton, windowsHitTest(1_175, 20, geometry))
}
@Test
fun nativeCaptionStripIsSplitIntoThreeAdjacentButtons() {
val bounds = splitWindowsCaptionButtonBounds(Rect(646f, 0f, 793f, 30f))
assertEquals(Rect(646f, 0f, 695f, 30f), bounds.minimize)
assertEquals(Rect(695f, 0f, 744f, 30f), bounds.maximize)
assertEquals(Rect(744f, 0f, 793f, 30f), bounds.close)
}
@Test
fun extendedDwmTitleBarMarginScalesWithWindowDpi() {
assertEquals(48, windowsTitleBarHeightPixels(96))
assertEquals(60, windowsTitleBarHeightPixels(120))
assertEquals(72, windowsTitleBarHeightPixels(144))
}
@Test
fun maximizedWindowsDoNotExposeResizeBorders() {
val geometry = windowsGeometry().copy(isMaximized = true)
assertEquals(WindowsHitTestResult.Caption, windowsHitTest(2, 2, geometry))
}
@Test
fun restoredWindowsPaintThroughTheResizeBorderWhileMaximizedWindowsStayInset() {
assertEquals(
WindowsFrameInsets(),
windowsContentInsets(
isMaximized = false,
horizontalResizeBorder = 8,
verticalResizeBorder = 8,
),
)
assertEquals(
WindowsFrameInsets(left = 8, top = 9, right = 8, bottom = 9),
windowsContentInsets(
isMaximized = true,
horizontalResizeBorder = 8,
verticalResizeBorder = 9,
),
)
}
@Test
fun nativeMouseCoordinatesPreserveNegativeMonitorPositions() {
val x = -320
val y = -48
val packed = ((y and 0xffff).toLong() shl 16) or (x and 0xffff).toLong()
assertEquals(WindowsScreenPoint(x, y), windowsScreenPoint(packed))
}
@Test
fun nativeBackdropRequiresAGpuRendererWithTransparentSwapChainSupport() {
assertTrue(GraphicsApi.DIRECT3D.supportsWindowsTransparentBackground())
assertTrue(GraphicsApi.OPENGL.supportsWindowsTransparentBackground())
assertFalse(GraphicsApi.UNKNOWN.supportsWindowsTransparentBackground())
assertFalse(GraphicsApi.SOFTWARE_FAST.supportsWindowsTransparentBackground())
assertFalse(GraphicsApi.SOFTWARE_COMPAT.supportsWindowsTransparentBackground())
}
@Test
fun captionActionRequiresReleaseOnTheOriginallyPressedButton() {
assertTrue(
shouldActivateWindowsCaptionButton(
WindowsCaptionButton.Maximize,
WindowsCaptionButton.Maximize,
),
)
assertFalse(
shouldActivateWindowsCaptionButton(
WindowsCaptionButton.Close,
WindowsCaptionButton.Maximize,
),
)
assertFalse(shouldActivateWindowsCaptionButton(WindowsCaptionButton.Close, null))
}
private fun windowsGeometry() = WindowsHitTestGeometry(
width = 1_200,
height = 800,
horizontalResizeBorder = 8,
verticalResizeBorder = 8,
isMaximized = false,
caption = Rect(0f, 0f, 1_200f, 48f),
minimizeButton = Rect(1_062f, 8f, 1_108f, 40f),
maximizeButton = Rect(1_108f, 8f, 1_154f, 40f),
closeButton = Rect(1_154f, 8f, 1_200f, 40f),
)
} }

View File

@@ -11,8 +11,9 @@ androidx-lifecycle = "2.11.0-beta01"
androidx-datastore = "1.2.1" androidx-datastore = "1.2.1"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
composeMultiplatform = "1.11.1" composeMultiplatform = "1.11.1"
compottie = "2.2.4" filekit = "0.14.2"
gobley = "0.3.7" gobley = "0.3.7"
jna = "5.19.1"
junit = "4.13.2" junit = "4.13.2"
kotlin = "2.4.0" kotlin = "2.4.0"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
@@ -41,7 +42,8 @@ compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMul
compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" } compose-uiTest = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" } compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
compottie-lite = { module = "io.github.alexzhirkevich:compottie-lite", version.ref = "compottie" } filekit-dialogs = { module = "io.github.vinceglb:filekit-dialogs", version.ref = "filekit" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }

View File

@@ -1281,6 +1281,20 @@
"ru": "Не удалось загрузить сведения об устройстве." "ru": "Не удалось загрузить сведения об устройстве."
} }
}, },
"error_destination_exists": {
"context": "Error: a received file would overwrite an existing destination file.",
"translations": {
"en": "A file with the same name already exists in the destination. Choose another folder or remove the existing file.",
"fr": "Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.",
"es": "Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.",
"it": "Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente.",
"de": "Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.",
"pt": "Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.",
"pl": "W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.",
"nl": "Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.",
"ru": "В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл."
}
},
"error_filesystem": { "error_filesystem": {
"context": "Error: the selected files/folder could not be accessed.", "context": "Error: the selected files/folder could not be accessed.",
"translations": { "translations": {
@@ -1323,6 +1337,20 @@
"ru": "VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку." "ru": "VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку."
} }
}, },
"error_invalid_input": {
"context": "Error: transfer input or metadata is invalid.",
"translations": {
"en": "Some transfer information is invalid. Review your selection or ask the sender to share again.",
"fr": "Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à lexpéditeur de partager à nouveau.",
"es": "Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla.",
"it": "Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo.",
"de": "Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen.",
"pt": "Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente.",
"pl": "Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie.",
"nl": "Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen.",
"ru": "Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова."
}
},
"error_invalid_ticket": { "error_invalid_ticket": {
"context": "Error: the invitation/ticket could not be parsed.", "context": "Error: the invitation/ticket could not be parsed.",
"translations": { "translations": {
@@ -1365,6 +1393,20 @@
"ru": "В этой сборке отсутствует нативная библиотека VniDrop." "ru": "В этой сборке отсутствует нативная библиотека VniDrop."
} }
}, },
"error_network": {
"context": "Error: the sender could not be reached over the local network.",
"translations": {
"en": "VniDrop could not reach the sender. Check the connection on both devices and try again.",
"fr": "VniDrop na pas pu joindre lexpéditeur. Vérifiez la connexion sur les deux appareils et réessayez.",
"es": "VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo.",
"it": "VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi.",
"de": "VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut.",
"pt": "O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente.",
"pl": "VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie.",
"nl": "VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw.",
"ru": "VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку."
}
},
"error_nfc": { "error_nfc": {
"context": "Error: the NFC tag could not be read/used.", "context": "Error: the NFC tag could not be read/used.",
"translations": { "translations": {
@@ -1463,18 +1505,32 @@
"ru": "VniDrop ещё запускается. Откройте приглашение снова через мгновение." "ru": "VniDrop ещё запускается. Откройте приглашение снова через мгновение."
} }
}, },
"error_transfer": { "error_storage_full": {
"context": "Error: the transfer could not be completed.", "context": "Error: the destination does not have enough free storage.",
"translations": { "translations": {
"en": "The transfer could not be completed. Check your connection and try again.", "en": "There is not enough storage space to save this transfer. Free up space and try again.",
"fr": "Le transfert na pas pu être terminé. Vérifiez votre connexion et réessayez.", "fr": "Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez.",
"es": "No se pudo completar la transferencia. Compruebe su conexión e inténtelo de nuevo.", "es": "No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo.",
"it": "Impossibile completare il trasferimento. Controlli la connessione e riprovi.", "it": "Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.",
"de": "Die Übertragung konnte nicht abgeschlossen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "de": "Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut.",
"pt": "Não foi possível concluir a transferência. Verifique a sua ligão e tente novamente.", "pt": "Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente.",
"pl": "Nie udało się ukończyć transferu. Sprawdź połączenie i spróbuj ponownie.", "pl": "Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie.",
"nl": "De overdracht kon niet worden voltooid. Controleer uw verbinding en probeer het opnieuw.", "nl": "Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw.",
"ru": "Не удалось завершить передачу. Проверьте подключение и повторите попытку." "ru": "Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку."
}
},
"error_transfer": {
"context": "Error: transfer data could not be processed; network failures use error_network.",
"translations": {
"en": "The transfer data could not be processed. Ask the sender to share it again.",
"fr": "Les données du transfert nont pas pu être traitées. Demandez à lexpéditeur de les partager à nouveau.",
"es": "No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos.",
"it": "Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.",
"de": "Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen.",
"pt": "Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente.",
"pl": "Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie.",
"nl": "De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen.",
"ru": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
} }
}, },
"field_receiver_name": { "field_receiver_name": {
@@ -2926,15 +2982,29 @@
"storage_delete_transfers_description": { "storage_delete_transfers_description": {
"context": "Settings > Storage: confirmation body for deleting all transfer records.", "context": "Settings > Storage: confirmation body for deleting all transfer records.",
"translations": { "translations": {
"en": "This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This cant be undone.", "en": "This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This cant be undone.",
"fr": "Cela efface tous les enregistrements de transferts envoyés et reçus de votre historique. Vos fichiers reçus ne sont pas supprimés. Remarque : le moteur de transfert conserve son contenu stocké, donc les données de transfert peuvent ne pas diminuer. Cette action est irréversible.", "fr": "Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui nest plus cessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.",
"es": "Esto borra todos los registros de transferencias enviadas y recibidas de su historial. Sus archivos recibidos no se eliminan. Nota: el motor de transferencia conserva su contenido almacenado, por lo que los datos de transferencia pueden no disminuir. Esto no se puede deshacer.", "es": "Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer.",
"it": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I suoi file ricevuti non vengono eliminati. Nota: il motore di trasferimento conserva il contenuto memorizzato, quindi i dati di trasferimento potrebbero non diminuire. Questa operazione non può essere annullata.", "it": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po di tempo. Questa azione non può essere annullata.",
"de": "Dies löscht alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf. Ihre empfangenen Dateien werden nicht gelöscht. Hinweis: Die Übertragungs-Engine behält ihre gespeicherten Inhalte, sodass die Übertragungsdaten möglicherweise nicht abnehmen. Dies kann nicht rückgängig gemacht werden.", "de": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden.",
"pt": "Isto elimina do seu histórico todos os registos de transferências enviadas e recebidas. Os seus ficheiros recebidos não são eliminados. Nota: o motor de transferência mantém o conteúdo armazenado, pelo que os dados de transferência podem não diminuir. Isto não pode ser anulado.", "pt": "Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada.",
"pl": "To usuwa z historii wszystkie rekordy wysłanych i odebranych transferów. Twoje odebrane pliki nie są usuwane. Uwaga: silnik transferu zachowuje przechowywaną zawartość, więc dane transferu mogą się nie zmniejszyć. Tej operacji nie można cofnąć.", "pl": "Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć.",
"nl": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Let op: de overdrachtsengine bewaart de opgeslagen inhoud, dus de overdrachtsgegevens nemen mogelijk niet af. Dit kan niet ongedaan worden gemaakt.", "nl": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt.",
"ru": "Это удалит из истории все записи об отправленных и полученных передачах. Ваши полученные файлы не удаляются. Примечание: механизм передачи сохраняет своё содержимое, поэтому объём данных передачи может не уменьшиться. Это действие нельзя отменить." "ru": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить."
}
},
"storage_app_data": {
"context": "Settings > Storage: label for non-transfer application data.",
"translations": {
"en": "App data",
"fr": "Données de lapp",
"es": "Datos de la aplicación",
"it": "Dati dellapp",
"de": "App-Daten",
"pt": "Dados da aplicação",
"pl": "Dane aplikacji",
"nl": "Appgegevens",
"ru": "Данные приложения"
} }
}, },
"storage_deleting": { "storage_deleting": {
@@ -2954,15 +3024,15 @@
"storage_footer": { "storage_footer": {
"context": "Settings > Storage: footer explaining how storage is managed.", "context": "Settings > Storage: footer explaining how storage is managed.",
"translations": { "translations": {
"en": "Transfer data is managed by the transfer engine — your history plus the content of files youve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here.", "en": "Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here.",
"fr": "Les données de transfert sont gérées par le moteur de transfert — votre historique ainsi que le contenu des fichiers que vous avez partagés. Le bouton ci-dessous efface vos enregistrements de transferts ; le moteur conserve son contenu stocké, donc cette valeur peut ne pas baisser. Les fichiers reçus sont vos fichiers téléchargés et ne sont jamais supprimés ici.", "fr": "Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici.",
"es": "Los datos de transferencia los gestiona el motor de transferencia: su historial más el contenido de los archivos que ha compartido. El botón de abajo borra sus registros de transferencias; el motor conserva su contenido almacenado, por lo que este valor puede no bajar. Los archivos recibidos son los archivos que ha descargado y nunca se eliminan aquí.", "es": "Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí.",
"it": "I dati di trasferimento sono gestiti dal motore di trasferimento: la sua cronologia più il contenuto dei file che ha condiviso. Il pulsante qui sotto cancella i record dei trasferimenti; il motore conserva il contenuto memorizzato, quindi questo valore potrebbe non diminuire. I file ricevuti sono i file che ha scaricato e non vengono mai eliminati qui.", "it": "I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui.",
"de": "Übertragungsdaten werden von der Übertragungs-Engine verwaltet Ihr Verlauf sowie der Inhalt der von Ihnen geteilten Dateien. Die Schaltfläche unten löscht Ihre Übertragungsdatensätze; die Engine behält ihre gespeicherten Inhalte, sodass dieser Wert möglicherweise nicht sinkt. Empfangene Dateien sind Ihre heruntergeladenen Dateien und werden hier niemals gelöscht.", "de": "Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht.",
"pt": "Os dados de transferência são geridos pelo motor de transferência — o seu histórico mais o conteúdo dos ficheiros que partilhou. O botão abaixo elimina os seus registos de transferências; o motor mantém o conteúdo armazenado, pelo que este valor pode não descer. Os ficheiros recebidos são os ficheiros que descarregou e nunca são eliminados aqui.", "pt": "Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui.",
"pl": "Danymi transferu zarządza silnik transferu — Twoja historia oraz zawartość udostępnionych plików. Przycisk poniżej usuwa rekordy transferów; silnik zachowuje przechowywaną zawartość, więc ta wartość może się nie zmniejszyć. Odebrane pliki to Twoje pobrane pliki i nigdy nie są tu usuwane.", "pl": "Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane.",
"nl": "Overdrachtsgegevens worden beheerd door de overdrachtsengine — uw geschiedenis plus de inhoud van de bestanden die u hebt gedeeld. De knop hieronder wist uw overdrachtsrecords; de engine bewaart de opgeslagen inhoud, dus deze waarde daalt mogelijk niet. Ontvangen bestanden zijn uw gedownloade bestanden en worden hier nooit verwijderd.", "nl": "Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd.",
"ru": "Данными передачи управляет механизм передачи — ваша история плюс содержимое файлов, которыми вы поделились. Кнопка ниже удаляет записи о передачах; механизм сохраняет своё содержимое, поэтому это значение может не уменьшиться. Полученные файлы — это загруженные вами файлы, и они никогда не удаляются здесь." "ru": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь."
} }
}, },
"storage_received_files": { "storage_received_files": {

View File

@@ -129,6 +129,9 @@ kotlin {
implementation(libs.google.code.scanner) implementation(libs.google.code.scanner)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
} }
jvmMain.dependencies {
implementation(libs.filekit.dialogs)
}
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
@@ -136,7 +139,6 @@ kotlin {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.compottie.lite)
implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)

View File

@@ -25,6 +25,7 @@ fun rememberAndroidAppDependencies(activity: ComponentActivity, externalInvitati
appVersion = context.appVersion(), appVersion = context.appVersion(),
defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath, defaultCoreDataDir = context.filesDir.resolve("vnidrop").absolutePath,
defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver", defaultUsername = Build.DEVICE.takeIf(String::isNotBlank) ?: "Receiver",
uiPlatform = UiPlatform.Android,
), ),
deviceInfoProvider = AndroidDeviceInfoProvider(context), deviceInfoProvider = AndroidDeviceInfoProvider(context),
fileSystemService = fileSystemService, fileSystemService = fileSystemService,

View File

@@ -7,13 +7,19 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
import android.system.ErrnoException
import android.system.OsConstants
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri import androidx.core.net.toUri
import uniffi.vnidrop.ReceiveOutputSink import uniffi.vnidrop.PublishedOutput
import uniffi.vnidrop.ReceiveOutputSinkV2
import uniffi.vnidrop.ReceivedLocatorKind
import uniffi.vnidrop.VnidropException
import java.io.File import java.io.File
import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.net.URLConnection import java.net.URLConnection
import java.util.UUID import java.util.UUID
@@ -54,7 +60,52 @@ private class AndroidFileSystemService(
ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value)
} }
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection {
var bytes = 0UL
var existing = 0
var missing = 0
var inaccessible = 0
for (artifact in artifacts) {
when (artifact.locatorKind) {
ReceivedLocatorKind.FILESYSTEM_PATH -> {
val file = File(artifact.locator)
if (file.isFile) {
bytes += file.length().toULong()
existing += 1
} else {
missing += 1
}
}
ReceivedLocatorKind.ANDROID_MEDIA_STORE, ReceivedLocatorKind.ANDROID_DOCUMENT -> {
val result = runCatching {
context.contentResolver.query(
artifact.locator.toUri(),
arrayOf(android.provider.OpenableColumns.SIZE),
null,
null,
null,
)?.use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getLong(0).coerceAtLeast(0L).toULong()
}
}
result.fold(
onSuccess = { size ->
if (size == null) missing += 1 else {
bytes += size
existing += 1
}
},
onFailure = { inaccessible += 1 },
)
}
}
}
return ReceivedStorageInspection(bytes, existing, missing, inaccessible)
}
override suspend fun temporaryUsage(): ULong = directorySize(context.cacheDir)
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? =
when (folder.kind) { when (folder.kind) {
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context) ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)
ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri()) ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri())
@@ -201,6 +252,32 @@ private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedSh
return out return out
} }
private inline fun <T> receiveSinkCall(block: () -> T): T =
try {
block()
} catch (error: VnidropException) {
throw error
} catch (error: Throwable) {
throw error.toReceiveSinkException()
}
private fun Throwable.toReceiveSinkException(): VnidropException {
val reason = message ?: toString()
var current: Throwable? = this
while (current != null) {
if (current is ErrnoException && current.errno == OsConstants.ENOSPC) {
return VnidropException.StorageFull(reason)
}
current = current.cause
}
return when (this) {
is SecurityException -> VnidropException.FilesystemPermission(reason)
is IllegalArgumentException -> VnidropException.InvalidInput(reason)
is IOException, is IllegalStateException -> VnidropException.Filesystem(reason)
else -> VnidropException.Internal(reason)
}
}
/** /**
* Writes into the shared system Downloads collection via MediaStore. * Writes into the shared system Downloads collection via MediaStore.
* *
@@ -209,7 +286,7 @@ private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedSh
*/ */
private class AndroidMediaStoreDownloadsSink( private class AndroidMediaStoreDownloadsSink(
private val context: Context, private val context: Context,
) : ReceiveOutputSink { ) : ReceiveOutputSinkV2 {
private data class PendingDocument( private data class PendingDocument(
val stream: OutputStream, val stream: OutputStream,
val uri: Uri, val uri: Uri,
@@ -219,6 +296,7 @@ private class AndroidMediaStoreDownloadsSink(
private val resolver = context.contentResolver private val resolver = context.contentResolver
override fun startFile(relativePath: String) { override fun startFile(relativePath: String) {
receiveSinkCall {
check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"MediaStore Downloads requires Android 10 or newer" "MediaStore Downloads requires Android 10 or newer"
} }
@@ -226,8 +304,8 @@ private class AndroidMediaStoreDownloadsSink(
val parts = requireSafeRelativePathParts(relativePath) val parts = requireSafeRelativePathParts(relativePath)
val finalName = parts.last() val finalName = parts.last()
val relativeDir = mediaStoreRelativePath(parts.dropLast(1)) val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
check(!mediaStoreItemExists(finalName, relativeDir)) { if (mediaStoreItemExists(finalName, relativeDir)) {
"Destination already exists: $relativePath" throw VnidropException.DestinationExists("Destination already exists: $relativePath")
} }
val values = ContentValues().apply { val values = ContentValues().apply {
@@ -248,13 +326,16 @@ private class AndroidMediaStoreDownloadsSink(
} }
pending[relativePath] = PendingDocument(stream, uri) pending[relativePath] = PendingDocument(stream, uri)
} }
}
override fun writeChunk(relativePath: String, bytes: ByteArray) { override fun writeChunk(relativePath: String, bytes: ByteArray) {
receiveSinkCall {
val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
document.stream.write(bytes) document.stream.write(bytes)
} }
}
override fun finishFile(relativePath: String) { override fun finishFile(relativePath: String): PublishedOutput = receiveSinkCall {
val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try { try {
document.stream.flush() document.stream.flush()
@@ -269,13 +350,16 @@ private class AndroidMediaStoreDownloadsSink(
resolver.delete(document.uri, null, null) resolver.delete(document.uri, null, null)
throw error throw error
} }
PublishedOutput(ReceivedLocatorKind.ANDROID_MEDIA_STORE, document.uri.toString())
} }
override fun abortFile(relativePath: String, reason: String) { override fun abortFile(relativePath: String, reason: String) {
val document = pending.remove(relativePath) ?: return receiveSinkCall {
val document = pending.remove(relativePath) ?: return@receiveSinkCall
runCatching { document.stream.close() } runCatching { document.stream.close() }
resolver.delete(document.uri, null, null) resolver.delete(document.uri, null, null)
} }
}
private fun downloadsCollection(): Uri = private fun downloadsCollection(): Uri =
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
@@ -331,7 +415,7 @@ private class AndroidMediaStoreDownloadsSink(
private class AndroidTreeReceiveOutputSink( private class AndroidTreeReceiveOutputSink(
private val context: Context, private val context: Context,
private val treeUri: Uri, private val treeUri: Uri,
) : ReceiveOutputSink { ) : ReceiveOutputSinkV2 {
private data class PendingDocument( private data class PendingDocument(
val stream: OutputStream, val stream: OutputStream,
val temporaryUri: Uri, val temporaryUri: Uri,
@@ -342,11 +426,14 @@ private class AndroidTreeReceiveOutputSink(
private val pending = mutableMapOf<String, PendingDocument>() private val pending = mutableMapOf<String, PendingDocument>()
override fun startFile(relativePath: String) { override fun startFile(relativePath: String) {
receiveSinkCall {
check(relativePath !in pending) { "Output stream is already open for $relativePath" } check(relativePath !in pending) { "Output stream is already open for $relativePath" }
// Defense in depth: Rust also validates, but sinks must reject traversal alone. // Defense in depth: Rust also validates, but sinks must reject traversal alone.
requireSafeRelativePathParts(relativePath) requireSafeRelativePathParts(relativePath)
val (parent, finalName) = resolveParent(relativePath) val (parent, finalName) = resolveParent(relativePath)
check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" } if (findChild(parent, finalName) != null) {
throw VnidropException.DestinationExists("Destination already exists: $relativePath")
}
val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part" val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part"
val temporaryUri = DocumentsContract.createDocument( val temporaryUri = DocumentsContract.createDocument(
context.contentResolver, context.contentResolver,
@@ -358,24 +445,30 @@ private class AndroidTreeReceiveOutputSink(
?: error("Could not open output stream for $relativePath") ?: error("Could not open output stream for $relativePath")
pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName) pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName)
} }
}
override fun writeChunk(relativePath: String, bytes: ByteArray) { override fun writeChunk(relativePath: String, bytes: ByteArray) {
receiveSinkCall {
val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
document.stream.write(bytes) document.stream.write(bytes)
} }
}
override fun finishFile(relativePath: String) { override fun finishFile(relativePath: String): PublishedOutput = receiveSinkCall {
val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try { try {
document.stream.close() document.stream.close()
check(findChild(document.parentUri, document.finalName) == null) { "Destination already exists: $relativePath" } if (findChild(document.parentUri, document.finalName) != null) {
checkNotNull( throw VnidropException.DestinationExists("Destination already exists: $relativePath")
}
val finalUri = checkNotNull(
DocumentsContract.renameDocument( DocumentsContract.renameDocument(
context.contentResolver, context.contentResolver,
document.temporaryUri, document.temporaryUri,
document.finalName, document.finalName,
), ),
) { "Could not commit received file $relativePath" } ) { "Could not commit received file $relativePath" }
PublishedOutput(ReceivedLocatorKind.ANDROID_DOCUMENT, finalUri.toString())
} catch (error: Throwable) { } catch (error: Throwable) {
runCatching { document.stream.close() } runCatching { document.stream.close() }
DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri)
@@ -384,10 +477,12 @@ private class AndroidTreeReceiveOutputSink(
} }
override fun abortFile(relativePath: String, reason: String) { override fun abortFile(relativePath: String, reason: String) {
val document = pending.remove(relativePath) ?: return receiveSinkCall {
val document = pending.remove(relativePath) ?: return@receiveSinkCall
runCatching { document.stream.close() } runCatching { document.stream.close() }
DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri)
} }
}
private fun resolveParent(relativePath: String): Pair<Uri, String> { private fun resolveParent(relativePath: String): Pair<Uri, String> {
val parts = requireSafeRelativePathParts(relativePath) val parts = requireSafeRelativePathParts(relativePath)
@@ -441,3 +536,8 @@ private fun requireSafeRelativePathParts(relativePath: String): List<String> {
} }
return parts return parts
} }
private fun directorySize(directory: File): ULong =
if (!directory.exists()) 0UL else directory.walkTopDown()
.filter(File::isFile)
.fold(0UL) { total, file -> total + file.length().toULong() }

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12 3.25C12.4142 3.25 12.75 3.58579 12.75 4V11.25H20C20.4142 11.25 20.75 11.5858 20.75 12C20.75 12.4142 20.4142 12.75 20 12.75H12.75V20C12.75 20.4142 12.4142 20.75 12 20.75C11.5858 20.75 11.25 20.4142 11.25 20V12.75H4C3.58579 12.75 3.25 12.4142 3.25 12C3.25 11.5858 3.58579 11.25 4 11.25H11.25V4C11.25 3.58579 11.5858 3.25 12 3.25Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10.7327 19.7905C11.0326 20.0762 11.5074 20.0646 11.7931 19.7647C12.0788 19.4648 12.0672 18.99 11.7673 18.7043L5.51587 12.7497L20.25 12.7497C20.6642 12.7497 21 12.4139 21 11.9997C21 11.5855 20.6642 11.2497 20.25 11.2497L5.51577 11.2497L11.7673 5.29502C12.0672 5.00933 12.0787 4.5346 11.7931 4.23467C11.5074 3.93475 11.0326 3.9232 10.7327 4.20889L3.31379 11.2756C3.14486 11.4365 3.04491 11.6417 3.01393 11.8551C3.00479 11.9019 3 11.9503 3 11.9997C3 12.0493 3.00481 12.0977 3.01398 12.1446C3.04502 12.3579 3.14496 12.563 3.31379 12.7238L10.7327 19.7905Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12.0001 1.99609C16.05 1.99609 19.3568 5.19084 19.4959 9.24515L19.5001 9.49609V13.5931L20.8801 16.7491C20.9492 16.907 20.9848 17.0775 20.9848 17.2499C20.9848 17.9402 20.4252 18.4999 19.7348 18.4999L15.0001 18.5014C15.0001 20.1582 13.657 21.5014 12.0001 21.5014C10.4024 21.5014 9.09645 20.2524 9.0052 18.6776L8.99966 18.4991L4.27498 18.4999C4.10364 18.4999 3.93413 18.4646 3.77697 18.3964C3.14377 18.1213 2.85342 17.3851 3.12846 16.7519L4.50011 13.594V9.49599C4.50071 5.3412 7.8522 1.99609 12.0001 1.99609ZM13.4997 18.4991L10.5001 18.5014C10.5001 19.3298 11.1717 20.0014 12.0001 20.0014C12.7798 20.0014 13.4206 19.4065 13.4932 18.6458L13.4997 18.4991ZM12.0001 3.49609C8.67995 3.49609 6.00059 6.17035 6.00011 9.49609V13.9057L4.65613 16.9999H19.3526L18.0001 13.9067L18.0002 9.50895L17.9965 9.28375C17.8854 6.05027 15.2417 3.49609 12.0001 3.49609Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10.5 2.75195C10.5 2.33774 10.1642 2.00195 9.75 2.00195C9.33579 2.00195 9 2.33774 9 2.75195V3.50406C9 4.13652 9.19571 4.72327 9.52988 5.20705C8.13703 5.68607 7.11506 6.9624 7.00909 8.49092H6.76245L6.75315 8.4909C5.51052 8.48576 4.50733 7.47425 4.51247 6.23162L4.51861 4.74602C4.52032 4.33181 4.18593 3.99464 3.77172 3.99293C3.35751 3.99121 3.02034 4.32561 3.01863 4.73982L3.01248 6.22542C3.00392 8.29647 4.6759 9.98232 6.74695 9.99089L6.76245 9.99092H7V11.5006H2.75C2.33579 11.5006 2 11.8364 2 12.2506C2 12.6648 2.33579 13.0006 2.75 13.0006H7V14.9929H6.76245L6.74695 14.9929C4.6759 15.0015 3.00392 16.6874 3.01248 18.7584L3.01863 20.244C3.02034 20.6582 3.35751 20.9926 3.77172 20.9909C4.18593 20.9892 4.52032 20.652 4.51861 20.2378L4.51247 18.7522C4.50733 17.5096 5.51052 16.4981 6.75315 16.4929L6.76245 16.4929H7.22633C7.86093 18.5257 9.75816 20.0011 12 20.0011C14.2418 20.0011 16.1391 18.5257 16.7737 16.4929H17.2375L17.2468 16.4929C18.4895 16.4981 19.4927 17.5096 19.4875 18.7522L19.4814 20.2378C19.4797 20.652 19.8141 20.9892 20.2283 20.9909C20.6425 20.9926 20.9797 20.6582 20.9814 20.244L20.9875 18.7584C20.9961 16.6874 19.3241 15.0015 17.253 14.9929L17.2375 14.9929H17V13.0006H21.2514C21.6656 13.0006 22.0014 12.6648 22.0014 12.2506C22.0014 11.8364 21.6656 11.5006 21.2514 11.5006H17V9.99092H17.2375L17.253 9.99089C19.3241 9.98232 20.9961 8.29647 20.9875 6.22542L20.9814 4.73982C20.9797 4.32561 20.6425 3.99121 20.2283 3.99293C19.8141 3.99464 19.4797 4.33181 19.4814 4.74602L19.4875 6.23162C19.4927 7.47425 18.4895 8.48576 17.2468 8.4909L17.2375 8.49092H16.9909C16.8849 6.9624 15.863 5.68607 14.4701 5.20706C14.8043 4.72328 15 4.13653 15 3.50406V2.75195C15 2.33774 14.6642 2.00195 14.25 2.00195C13.8358 2.00195 13.5 2.33774 13.5 2.75195V3.50406C13.5 4.33249 12.8284 5.00406 12 5.00406C11.1716 5.00406 10.5 4.33249 10.5 3.50406V2.75195ZM8.5 8.75409C8.5 7.51145 9.50736 6.50409 10.75 6.50409H13.25C14.4926 6.50409 15.5 7.51145 15.5 8.75409V15.0011C15.5 16.9341 13.933 18.5011 12 18.5011C10.067 18.5011 8.5 16.9341 8.5 15.0011V8.75409Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M4.53033 12.9697C4.23744 12.6768 3.76256 12.6768 3.46967 12.9697C3.17678 13.2626 3.17678 13.7374 3.46967 14.0303L7.96967 18.5303C8.26256 18.8232 8.73744 18.8232 9.03033 18.5303L20.0303 7.53033C20.3232 7.23744 20.3232 6.76256 20.0303 6.46967C19.7374 6.17678 19.2626 6.17678 18.9697 6.46967L8.5 16.9393L4.53033 12.9697Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M8.46967 4.21967C8.17678 4.51256 8.17678 4.98744 8.46967 5.28033L15.1893 12L8.46967 18.7197C8.17678 19.0126 8.17678 19.4874 8.46967 19.7803C8.76256 20.0732 9.23744 20.0732 9.53033 19.7803L16.7803 12.5303C17.0732 12.2374 17.0732 11.7626 16.7803 11.4697L9.53033 4.21967C9.23744 3.92678 8.76256 3.92678 8.46967 4.21967Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M4.39705 4.55379L4.46967 4.46967C4.73594 4.2034 5.1526 4.1792 5.44621 4.39705L5.53033 4.46967L12 10.939L18.4697 4.46967C18.7626 4.17678 19.2374 4.17678 19.5303 4.46967C19.8232 4.76256 19.8232 5.23744 19.5303 5.53033L13.061 12L19.5303 18.4697C19.7966 18.7359 19.8208 19.1526 19.6029 19.4462L19.5303 19.5303C19.2641 19.7966 18.8474 19.8208 18.5538 19.6029L18.4697 19.5303L12 13.061L5.53033 19.5303C5.23744 19.8232 4.76256 19.8232 4.46967 19.5303C4.17678 19.2374 4.17678 18.7626 4.46967 18.4697L10.939 12L4.46967 5.53033C4.2034 5.26406 4.1792 4.8474 4.39705 4.55379L4.46967 4.46967L4.39705 4.55379Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M3.28034 2.21968C2.98745 1.92678 2.51257 1.92677 2.21968 2.21966C1.92678 2.51255 1.92677 2.98743 2.21966 3.28032L6.85339 7.91414C6.47198 8.54894 6.20466 9.26014 6.07981 10.0194C3.79155 10.2313 2 12.1564 2 14.5C2 16.9853 4.01472 19 6.5 19H17.5C17.6415 19 17.7815 18.9935 17.9197 18.9807L20.7194 21.7805C21.0123 22.0734 21.4872 22.0734 21.7801 21.7805C22.073 21.4876 22.073 21.0127 21.7801 20.7198L3.28034 2.21968ZM16.4391 17.5H6.5C4.84315 17.5 3.5 16.1569 3.5 14.5C3.5 12.8431 4.84315 11.5 6.5 11.5H6.75585C7.15641 11.5 7.48627 11.1852 7.50502 10.7851C7.53463 10.1537 7.69446 9.55623 7.95827 9.01904L16.4391 17.5ZM20.5 14.5C20.5 15.2822 20.2007 15.9944 19.7103 16.5285L20.7716 17.5898C21.5331 16.7838 22 15.6964 22 14.5C22 12.1564 20.2085 10.2313 17.9202 10.0194C17.4519 7.17189 14.9798 5 12 5C10.9031 5 9.875 5.29431 8.99031 5.80828L10.1011 6.91911C10.6781 6.65018 11.3215 6.5 12 6.5C14.4132 6.5 16.3832 8.39994 16.495 10.7851C16.5137 11.1852 16.8436 11.5 17.2442 11.5H17.5C19.1569 11.5 20.5 12.8431 20.5 14.5Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M8.06562 18.9434L14.5656 4.44339C14.7351 4.06542 15.1788 3.89637 15.5568 4.0658C15.9033 4.22112 16.0742 4.60695 15.9698 4.96131L15.9344 5.05698L9.43438 19.557C9.26495 19.935 8.82118 20.104 8.44321 19.9346C8.09673 19.7793 7.92581 19.3934 8.03024 19.0391L8.06562 18.9434L14.5656 4.44339L8.06562 18.9434ZM2.21967 11.4699L6.46967 7.21986C6.76256 6.92696 7.23744 6.92696 7.53033 7.21986C7.7966 7.48612 7.8208 7.90279 7.60295 8.1964L7.53033 8.28052L3.81066 12.0002L7.53033 15.7199C7.82322 16.0127 7.82322 16.4876 7.53033 16.7805C7.26406 17.0468 6.8474 17.071 6.55379 16.8531L6.46967 16.7805L2.21967 12.5305C1.9534 12.2642 1.9292 11.8476 2.14705 11.554L2.21967 11.4699L6.46967 7.21986L2.21967 11.4699ZM16.4697 7.21986C16.7359 6.95359 17.1526 6.92938 17.4462 7.14724L17.5303 7.21986L21.7803 11.4699C22.0466 11.7361 22.0708 12.1528 21.8529 12.4464L21.7803 12.5305L17.5303 16.7805C17.2374 17.0734 16.7626 17.0734 16.4697 16.7805C16.2034 16.5143 16.1792 16.0976 16.3971 15.804L16.4697 15.7199L20.1893 12.0002L16.4697 8.28052C16.1768 7.98762 16.1768 7.51275 16.4697 7.21986Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10 5H14C14 3.89543 13.1046 3 12 3C10.8954 3 10 3.89543 10 5ZM8.5 5C8.5 3.067 10.067 1.5 12 1.5C13.933 1.5 15.5 3.067 15.5 5H21.25C21.6642 5 22 5.33579 22 5.75C22 6.16421 21.6642 6.5 21.25 6.5H19.9309L18.7589 18.6112C18.5729 20.5334 16.9575 22 15.0263 22H8.97369C7.04254 22 5.42715 20.5334 5.24113 18.6112L4.06908 6.5H2.75C2.33579 6.5 2 6.16421 2 5.75C2 5.33579 2.33579 5 2.75 5H8.5ZM10.5 9.75C10.5 9.33579 10.1642 9 9.75 9C9.33579 9 9 9.33579 9 9.75V17.25C9 17.6642 9.33579 18 9.75 18C10.1642 18 10.5 17.6642 10.5 17.25V9.75ZM14.25 9C14.6642 9 15 9.33579 15 9.75V17.25C15 17.6642 14.6642 18 14.25 18C13.8358 18 13.5 17.6642 13.5 17.25V9.75C13.5 9.33579 13.8358 9 14.25 9ZM6.73416 18.4667C6.84577 19.62 7.815 20.5 8.97369 20.5H15.0263C16.185 20.5 17.1542 19.62 17.2658 18.4667L18.4239 6.5H5.57608L6.73416 18.4667Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M6 2C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V9.82777C20 9.29733 19.7893 8.78863 19.4142 8.41355L13.5864 2.58579C13.2114 2.21071 12.7027 2 12.1722 2H6ZM5.5 4C5.5 3.72386 5.72386 3.5 6 3.5H12V8C12 9.10457 12.8954 10 14 10H18.5V20C18.5 20.2761 18.2761 20.5 18 20.5H6C5.72386 20.5 5.5 20.2761 5.5 20V4ZM17.3793 8.5H14C13.7239 8.5 13.5 8.27614 13.5 8V4.62066L17.3793 8.5Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M18.2498 20.5009C18.664 20.5008 19 20.8365 19 21.2507C19 21.6649 18.6644 22.0008 18.2502 22.0009L5.25022 22.0047C4.836 22.0048 4.5 21.6691 4.5 21.2549C4.5 20.8407 4.83557 20.5048 5.24978 20.5047L18.2498 20.5009ZM11.6482 2.01271L11.75 2.00586C12.1297 2.00586 12.4435 2.28801 12.4932 2.65409L12.5 2.75586L12.499 16.4409L16.2208 12.7205C16.4871 12.4543 16.9038 12.4301 17.1974 12.648L17.2815 12.7206C17.5477 12.9869 17.5719 13.4036 17.354 13.6972L17.2814 13.7813L12.2837 18.7779C12.0176 19.044 11.6012 19.0683 11.3076 18.8507L11.2235 18.7782L6.22003 13.7816C5.92694 13.4889 5.92661 13.014 6.21931 12.7209C6.48539 12.4545 6.90204 12.43 7.1958 12.6477L7.27997 12.7202L10.999 16.4339L11 2.75586C11 2.37616 11.2822 2.06237 11.6482 2.01271L11.75 2.00586L11.6482 2.01271Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M6 2C4.89543 2 4 2.89543 4 4V20C4 21.1046 4.89543 22 6 22H18C19.1046 22 20 21.1046 20 20V9.82777C20 9.29733 19.7893 8.78863 19.4142 8.41355L13.5864 2.58579C13.2114 2.21071 12.7027 2 12.1722 2H6ZM5.5 4C5.5 3.72386 5.72386 3.5 6 3.5H12V8C12 9.10457 12.8954 10 14 10H18.5V20C18.5 20.2761 18.2761 20.5 18 20.5H6C5.72386 20.5 5.5 20.2761 5.5 20V4ZM17.3793 8.5H14C13.7239 8.5 13.5 8.27614 13.5 8V4.62066L17.3793 8.5Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M3.5 6.25V8H8.12868C8.32759 8 8.51836 7.92098 8.65901 7.78033L10.1893 6.25L8.65901 4.71967C8.51836 4.57902 8.32759 4.5 8.12868 4.5H5.25C4.2835 4.5 3.5 5.2835 3.5 6.25ZM2 6.25C2 4.45507 3.45507 3 5.25 3H8.12868C8.72542 3 9.29771 3.23705 9.71967 3.65901L11.5607 5.5H18.75C20.5449 5.5 22 6.95507 22 8.75V17.75C22 19.5449 20.5449 21 18.75 21H5.25C3.45507 21 2 19.5449 2 17.75V6.25ZM3.5 9.5V17.75C3.5 18.7165 4.2835 19.5 5.25 19.5H18.75C19.7165 19.5 20.5 18.7165 20.5 17.75V8.75C20.5 7.7835 19.7165 7 18.75 7H11.5607L9.71967 8.84099C9.29771 9.26295 8.72542 9.5 8.12868 9.5H3.5Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12.0001 1.99805C17.5238 1.99805 22.0016 6.47589 22.0016 11.9996C22.0016 17.5233 17.5238 22.0011 12.0001 22.0011C6.47638 22.0011 1.99854 17.5233 1.99854 11.9996C1.99854 6.47589 6.47638 1.99805 12.0001 1.99805ZM14.939 16.4993H9.06118C9.71322 18.9135 10.8466 20.5011 12.0001 20.5011C13.1536 20.5011 14.2869 18.9135 14.939 16.4993ZM7.5084 16.4999L4.78591 16.4998C5.74425 18.0328 7.1777 19.2384 8.88008 19.9104C8.3578 19.0906 7.92681 18.0643 7.60981 16.8949L7.5084 16.4999ZM19.2143 16.4998L16.4918 16.4999C16.168 17.8337 15.7004 18.9995 15.119 19.9104C16.716 19.2804 18.0757 18.1814 19.0291 16.7833L19.2143 16.4998ZM7.09351 9.99895H3.7359L3.73115 10.0162C3.57906 10.6525 3.49854 11.3166 3.49854 11.9996C3.49854 13.0558 3.69112 14.0669 4.0431 14.9999L7.21626 14.9995C7.07396 14.0504 6.99854 13.0422 6.99854 11.9996C6.99854 11.3156 7.031 10.6464 7.09351 9.99895ZM15.397 9.99901H8.60316C8.53514 10.6393 8.49853 11.309 8.49853 11.9996C8.49853 13.0591 8.58468 14.0694 8.73827 14.9997H15.2619C15.4155 14.0694 15.5016 13.0591 15.5016 11.9996C15.5016 11.309 15.465 10.6393 15.397 9.99901ZM20.2647 9.99811L16.9067 9.99897C16.9692 10.6464 17.0016 11.3156 17.0016 11.9996C17.0016 13.0422 16.9262 14.0504 16.7839 14.9995L19.9571 14.9999C20.309 14.0669 20.5016 13.0558 20.5016 11.9996C20.5016 11.3102 20.4196 10.64 20.2647 9.99811ZM8.88114 4.08875L8.85823 4.09747C6.81092 4.91218 5.1549 6.49949 4.25023 8.49935L7.29835 8.49972C7.61171 6.74693 8.15855 5.221 8.88114 4.08875ZM12.0001 3.49805L11.8844 3.50335C10.619 3.6191 9.39651 5.62107 8.8288 8.4993H15.1714C14.6052 5.62914 13.388 3.63033 12.1264 3.50436L12.0001 3.49805ZM15.1201 4.08881L15.2269 4.2629C15.8961 5.37537 16.4043 6.83525 16.7018 8.49972L19.7499 8.49935C18.8853 6.58795 17.3343 5.05341 15.4113 4.21008L15.1201 4.08881Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M11.9981 3.99585V10.249C11.9981 10.6632 12.3339 10.999 12.7481 10.999C13.1623 10.999 13.4981 10.6632 13.4981 10.249V5.22247C13.4981 4.98301 13.7023 4.74664 13.9959 4.74493C14.2584 4.74332 14.5 4.9895 14.5 5.24902V12.749C14.5 13.0567 14.6879 13.3332 14.974 13.4464C15.2593 13.5593 15.5846 13.487 15.7952 13.2641L15.7959 13.2633L15.7985 13.2606C15.8026 13.2564 15.8101 13.2489 15.8208 13.2383C15.8422 13.2171 15.8766 13.184 15.9226 13.1425C16.0151 13.059 16.1521 12.9435 16.3241 12.8218C16.6758 12.5728 17.1332 12.3262 17.6268 12.2398C18.1641 12.1456 18.6737 12.2031 19.0124 12.378C19.221 12.4857 19.3843 12.643 19.4577 12.9064L17.7993 14.1531C17.7713 14.1742 17.7448 14.1972 17.7199 14.222L15.5447 16.395C14.6101 17.3286 13.8326 18.4071 13.242 19.5887C12.9628 20.1474 12.3918 20.5004 11.7672 20.5004H9.03943C8.46764 20.5004 7.97061 20.2276 7.7136 19.7873C6.97783 18.527 6 16.4995 6 14.7526V6.99902C6 6.73061 6.21505 6.49896 6.50115 6.49896C6.79011 6.49896 7.00409 6.72967 7.00409 6.99902V10.499C7.00409 10.9132 7.33988 11.249 7.75409 11.249C8.1683 11.249 8.50409 10.9132 8.50409 10.499V5.24902C8.50409 4.97076 8.71851 4.74495 9 4.74495C9.28688 4.74495 9.5 4.97053 9.5 5.24903V10.249C9.5 10.6632 9.83579 10.999 10.25 10.999C10.6642 10.999 11 10.6632 11 10.249V3.99585C11 3.72318 11.2121 3.49632 11.4986 3.49609C11.789 3.49587 11.9981 3.71909 11.9981 3.99585ZM13.9866 3.24496C13.7753 3.24621 13.5744 3.27896 13.3872 3.338ZM13.3872 3.338C13.1247 2.57945 12.4176 1.99538 11.4974 1.99609C10.5817 1.99681 9.87538 2.58206 9.61197 3.33746C9.42187 3.27761 9.21687 3.24495 9 3.24495C7.8915 3.24495 7.10363 4.10059 7.01285 5.06309C6.85127 5.02137 6.6801 4.99896 6.50115 4.99896C5.33182 4.99896 4.5 5.95807 4.5 6.99902V14.7526C4.5 16.9332 5.67139 19.2644 6.4182 20.5436C6.97463 21.4967 7.99781 22.0004 9.03943 22.0004H11.7672C12.9601 22.0004 14.0505 21.3263 14.5838 20.2593C15.1021 19.2222 15.7846 18.2756 16.6048 17.4562L18.7428 15.3205L20.7007 13.8485C20.8891 13.7068 21 13.4848 21 13.249C21 12.1874 20.4687 11.4419 19.7008 11.0453C18.9851 10.6756 18.1185 10.6307 17.3678 10.7623C16.8491 10.8532 16.3849 11.0457 16 11.2571V5.24902C16 4.18865 15.1118 3.2382 13.9866 3.24496" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12.0016 1.99902C17.5253 1.99902 22.0031 6.47687 22.0031 12.0006C22.0031 17.5243 17.5253 22.0021 12.0016 22.0021C6.47785 22.0021 2 17.5243 2 12.0006C2 6.47687 6.47785 1.99902 12.0016 1.99902ZM12.0016 3.49902C7.30627 3.49902 3.5 7.3053 3.5 12.0006C3.5 16.6959 7.30627 20.5021 12.0016 20.5021C16.6968 20.5021 20.5031 16.6959 20.5031 12.0006C20.5031 7.3053 16.6968 3.49902 12.0016 3.49902ZM12 10.5C12.4142 10.5 12.75 10.8358 12.75 11.25V16.25C12.75 16.6642 12.4142 17 12 17C11.5858 17 11.25 16.6642 11.25 16.25V11.25C11.25 10.8358 11.5858 10.5 12 10.5ZM12 9C12.5523 9 13 8.55229 13 8C13 7.44772 12.5523 7 12 7C11.4477 7 11 7.44772 11 8C11 8.55229 11.4477 9 12 9Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12 1C14.7614 1 17 3.23858 17 6V8.00977C18.6781 8.13743 20 9.5392 20 11.25V18.75C20 20.5449 18.5449 22 16.75 22H7.25C5.45507 22 4 20.5449 4 18.75V11.25C4 9.5392 5.3219 8.13743 7 8.00977V6C7 3.23858 9.23858 1 12 1ZM7.25 9.5C6.2835 9.5 5.5 10.2835 5.5 11.25V18.75C5.5 19.7165 6.2835 20.5 7.25 20.5H16.75C17.7165 20.5 18.5 19.7165 18.5 18.75V11.25C18.5 10.2835 17.7165 9.5 16.75 9.5H7.25ZM12 13.75C12.6904 13.75 13.25 14.3096 13.25 15C13.25 15.6904 12.6904 16.25 12 16.25C11.3096 16.25 10.75 15.6904 10.75 15C10.75 14.3096 11.3096 13.75 12 13.75ZM12 2.5C10.067 2.5 8.5 4.067 8.5 6V8H15.5V6C15.5 4.067 13.933 2.5 12 2.5Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M21.9068 5.62236C21.9686 5.83039 22 6.04627 22 6.26329V17.7387C22 18.9813 20.9926 19.9887 19.75 19.9887C19.5329 19.9887 19.317 19.9573 19.1089 19.8954L13.595 18.2558C12.9378 19.6008 11.5584 20.4994 10 20.4994C7.8578 20.4994 6.10892 18.8155 6.0049 16.6991L6 16.4994L5.999 15.9987L3.60891 15.288C2.65446 15.0043 2 14.127 2 13.1313V10.8693C2 9.87356 2.65455 8.99622 3.60908 8.71256L19.1091 4.1065C20.3002 3.75253 21.5528 4.4312 21.9068 5.62236ZM7.499 16.4447L7.5 16.4994C7.5 17.8801 8.61929 18.9994 10 18.9994C10.8852 18.9994 11.6783 18.5352 12.1238 17.82L7.499 16.4447ZM19.5364 5.54436L4.03636 10.1504C3.71818 10.245 3.5 10.5374 3.5 10.8693V13.1313C3.5 13.4632 3.71815 13.7556 4.0363 13.8502L19.5363 18.4576C19.6057 18.4782 19.6776 18.4887 19.75 18.4887C20.1642 18.4887 20.5 18.1529 20.5 17.7387V6.26329C20.5 6.19095 20.4895 6.11899 20.4689 6.04964C20.3509 5.65259 19.9334 5.42637 19.5364 5.54436Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M20.0258 17.0014C17.2639 21.7851 11.1471 23.4241 6.3634 20.6622C5.06068 19.9101 3.964 18.8926 3.12872 17.6797C2.84945 17.2741 3.0301 16.7141 3.49369 16.5482C7.26112 15.1997 9.27892 13.6372 10.4498 11.4021C11.6825 9.04908 12.001 6.47162 11.1387 2.93862C11.0195 2.45008 11.4053 1.98492 11.9075 2.01186C13.4645 2.09539 14.9856 2.54263 16.3649 3.33903C21.1486 6.10088 22.7876 12.2177 20.0258 17.0014ZM11.7785 12.0981C10.5272 14.4867 8.46706 16.1972 4.96104 17.597C5.5693 18.2929 6.29275 18.8894 7.1134 19.3632C11.1796 21.7108 16.3791 20.3176 18.7267 16.2514C21.0744 12.1852 19.6812 6.98571 15.6149 4.63807C14.7379 4.1317 13.7951 3.79168 12.8228 3.62253C13.4699 7.00652 13.0525 9.66622 11.7785 12.0981Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M11.7498 7C13.0985 7 13.9286 7.96911 13.9955 9.32894L13.9998 9.50848V11.624L16.219 12.0269C16.3058 12.0427 16.392 12.062 16.4772 12.0848C18.1515 12.5321 19.1696 14.2081 18.8209 15.884L18.7782 16.0635L17.7301 19.9867C17.512 20.8028 16.8572 21.4232 16.0412 21.6031L15.8759 21.6331L13.4577 21.9801C12.5331 22.1128 11.6295 21.6605 11.1774 20.8567L11.098 20.7019L11.0689 20.6393C10.8341 20.1345 10.4817 19.6948 10.0426 19.3564L9.84916 19.2176L7.96543 17.9618L7.87154 17.9034L7.77415 17.851L5.41123 16.6756C5.16175 16.5515 5.00164 16.2993 4.99547 16.0207C4.97089 14.9121 5.46099 14.0568 6.41442 13.5801C7.11627 13.2291 8.04946 13.249 9.24062 13.5967L9.49983 13.6762V9.50848C9.49983 8.05521 10.3425 7 11.7498 7ZM11.7498 8.5C11.2899 8.5 11.0374 8.77494 11.0037 9.36636L10.9998 9.50848V14.7525C10.9998 15.2865 10.4576 15.6494 9.96394 15.4459C8.50331 14.8436 7.52252 14.7031 7.08524 14.9217C6.83124 15.0487 6.66502 15.2126 6.57544 15.4407L6.53699 15.5603L8.44224 16.508L8.62311 16.6053L8.79748 16.7137L10.6812 17.9695C11.3639 18.4247 11.922 19.0411 12.3071 19.7624L12.429 20.0067L12.4581 20.0692C12.5821 20.3358 12.8484 20.5023 13.136 20.5029L13.2446 20.4953L15.6629 20.1483C15.923 20.111 16.1417 19.941 16.2443 19.7046L16.2809 19.5995L17.3291 15.6763C17.5785 14.7426 17.0238 13.7834 16.0901 13.5339L16.0208 13.5169L13.1158 12.988C12.7948 12.9297 12.5527 12.6722 12.5074 12.3571L12.4998 12.25V9.50848C12.4998 8.81887 12.2452 8.5 11.7498 8.5ZM11.7487 2C15.7531 2 18.9993 5.24621 18.9993 9.25062C18.9993 10.2367 18.8024 11.1768 18.4459 12.0338C18.0705 11.702 17.6315 11.4335 17.1413 11.2489C17.3731 10.6271 17.4993 9.9536 17.4993 9.25062C17.4993 6.07464 14.9246 3.5 11.7487 3.5C8.57269 3.5 5.99805 6.07464 5.99805 9.25062C5.99805 10.4042 6.33772 11.4784 6.9225 12.3788C6.58477 12.4339 6.26691 12.5358 5.96721 12.6856C5.80624 12.7661 5.65453 12.8551 5.51229 12.9521C4.86855 11.8697 4.49805 10.6034 4.49805 9.25062C4.49805 5.24621 7.74426 2 11.7487 2ZM11.7487 4.50178C14.3714 4.50178 16.4975 6.62791 16.4975 9.25062C16.4975 9.86873 16.3794 10.4593 16.1646 11.0009L14.9998 10.7892V9.50848L14.9957 9.31367L14.9975 9.25062C14.9975 8.57003 14.7882 7.93832 14.4305 7.41633L14.3412 7.28383C13.7832 6.49647 12.8918 6 11.7498 6C10.5747 6 9.66502 6.52568 9.11074 7.35305C8.72658 7.88672 8.49983 8.54219 8.49983 9.25062L8.50216 9.36135L8.49983 9.50848V12.3984L8.23771 12.3621C8.0003 12.3324 7.78141 12.2186 7.62077 12.0413C7.20681 11.5844 6.99983 10.6541 6.99983 9.25062C6.99983 6.62791 9.12596 4.50178 11.7487 4.50178Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M8 6H6V8H8V6ZM3 5.25C3 4.00736 4.00736 3 5.25 3H8.75C9.99264 3 11 4.00736 11 5.25V8.75C11 9.99264 9.99264 11 8.75 11H5.25C4.00736 11 3 9.99264 3 8.75V5.25ZM5.25 4.5C4.83579 4.5 4.5 4.83579 4.5 5.25V8.75C4.5 9.16421 4.83579 9.5 5.25 9.5H8.75C9.16421 9.5 9.5 9.16421 9.5 8.75V5.25C9.5 4.83579 9.16421 4.5 8.75 4.5H5.25ZM6 16H8V18H6V16ZM3 15.25C3 14.0074 4.00736 13 5.25 13H8.75C9.99264 13 11 14.0074 11 15.25V18.75C11 19.9926 9.99264 21 8.75 21H5.25C4.00736 21 3 19.9926 3 18.75V15.25ZM5.25 14.5C4.83579 14.5 4.5 14.8358 4.5 15.25V18.75C4.5 19.1642 4.83579 19.5 5.25 19.5H8.75C9.16421 19.5 9.5 19.1642 9.5 18.75V15.25C9.5 14.8358 9.16421 14.5 8.75 14.5H5.25ZM18 6H16V8H18V6ZM15.25 3C14.0074 3 13 4.00736 13 5.25V8.75C13 9.99264 14.0074 11 15.25 11H18.75C19.9926 11 21 9.99264 21 8.75V5.25C21 4.00736 19.9926 3 18.75 3H15.25ZM14.5 5.25C14.5 4.83579 14.8358 4.5 15.25 4.5H18.75C19.1642 4.5 19.5 4.83579 19.5 5.25V8.75C19.5 9.16421 19.1642 9.5 18.75 9.5H15.25C14.8358 9.5 14.5 9.16421 14.5 8.75V5.25ZM13 13H15.75V15.75H13V13ZM18.25 15.75H15.75V18.25H13V21H15.75V18.25H18.25V21H21V18.25H18.25V15.75ZM18.25 15.75V13H21V15.75H18.25Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M3.75 17C4.1297 17 4.44349 17.2822 4.49315 17.6483L4.5 17.75V19.25C4.5 19.6642 4.16421 20 3.75 20C3.3703 20 3.05651 19.7179 3.00685 19.3518L3 19.25V17.75C3 17.3358 3.33579 17 3.75 17ZM11.75 11C12.1297 11 12.4435 11.2822 12.4932 11.6483L12.5 11.75V19.25C12.5 19.6642 12.1642 20 11.75 20C11.3703 20 11.0565 19.7179 11.0068 19.3518L11 19.25V11.75C11 11.3358 11.3358 11 11.75 11ZM15.75 8.00004C16.1297 8.00004 16.4435 8.28219 16.4932 8.64826L16.5 8.75004V19.25C16.5 19.6642 16.1642 20 15.75 20C15.3703 20 15.0565 19.7179 15.0068 19.3518L15 19.25V8.75004C15 8.33582 15.3358 8.00004 15.75 8.00004ZM19.7427 5.00004C20.1224 4.99639 20.4389 5.27526 20.4921 5.64054L20.5 5.74216V19.2432C20.504 19.6571 20.1715 19.9958 19.7573 19.9999C19.3776 20.0035 19.0611 19.7246 19.0079 19.3594L19 19.2577V5.7567C18.996 5.34284 19.3285 5.00409 19.7427 5.00004ZM7.75 14C8.1297 14 8.44349 14.2822 8.49315 14.6483L8.5 14.75V19.2488C8.5 19.663 8.16421 19.9988 7.75 19.9988C7.3703 19.9988 7.05651 19.7166 7.00685 19.3505L7 19.2488V14.75C7 14.3358 7.33579 14 7.75 14Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M5.25 3.5C4.2835 3.5 3.5 4.2835 3.5 5.25V8.25C3.5 8.66421 3.16421 9 2.75 9C2.33579 9 2 8.66421 2 8.25V5.25C2 3.45507 3.45507 2 5.25 2H8.25C8.66421 2 9 2.33579 9 2.75C9 3.16421 8.66421 3.5 8.25 3.5H5.25ZM5.25 20.5C4.2835 20.5 3.5 19.7165 3.5 18.75V15.75C3.5 15.3358 3.16421 15 2.75 15C2.33579 15 2 15.3358 2 15.75V18.75C2 20.5449 3.45507 22 5.25 22H8.25C8.66421 22 9 21.6642 9 21.25C9 20.8358 8.66421 20.5 8.25 20.5H5.25ZM20.5 5.25C20.5 4.2835 19.7165 3.5 18.75 3.5H15.75C15.3358 3.5 15 3.16421 15 2.75C15 2.33579 15.3358 2 15.75 2H18.75C20.5449 2 22 3.45507 22 5.25V8.25C22 8.66421 21.6642 9 21.25 9C20.8358 9 20.5 8.66421 20.5 8.25V5.25ZM18.75 20.5C19.7165 20.5 20.5 19.7165 20.5 18.75V15.75C20.5 15.3358 20.8358 15 21.25 15C21.6642 15 22 15.3358 22 15.75V18.75C22 20.5449 20.5449 22 18.75 22H15.75C15.3358 22 15 21.6642 15 21.25C15 20.8358 15.3358 20.5 15.75 20.5H18.75ZM7 7.75C7 7.33579 7.33579 7 7.75 7H16.25C16.6642 7 17 7.33579 17 7.75V9C17 9.41421 16.6642 9.75 16.25 9.75C15.8358 9.75 15.5 9.41421 15.5 9V8.5H12.75V15.5H14.25C14.6642 15.5 15 15.8358 15 16.25C15 16.6642 14.6642 17 14.25 17H9.75C9.33579 17 9 16.6642 9 16.25C9 15.8358 9.33579 15.5 9.75 15.5H11.25V8.5H8.5V9C8.5 9.41421 8.16421 9.75 7.75 9.75C7.33579 9.75 7 9.41421 7 9V7.75Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M5.69362 11.9997L2.29933 3.2715C2.0631 2.66403 2.65544 2.08309 3.2414 2.28959L3.33375 2.32885L21.3337 11.3288C21.852 11.588 21.8844 12.2975 21.4309 12.6129L21.3337 12.6705L3.33375 21.6705C2.75077 21.962 2.11746 21.426 2.2688 20.8234L2.29933 20.7278L5.69362 11.9997L2.29933 3.2715L5.69362 11.9997ZM4.4021 4.54007L7.01109 11.2491L13.6387 11.2497C14.0184 11.2497 14.3322 11.5318 14.3818 11.8979L14.3887 11.9997C14.3887 12.3794 14.1065 12.6932 13.7404 12.7428L13.6387 12.7497L7.01109 12.7491L4.4021 19.4593L19.3213 11.9997L4.4021 4.54007Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12.0122 2.25C12.7462 2.25846 13.4773 2.34326 14.1937 2.50304C14.5064 2.57279 14.7403 2.83351 14.7758 3.15196L14.946 4.67881C15.0231 5.37986 15.615 5.91084 16.3206 5.91158C16.5103 5.91188 16.6979 5.87238 16.8732 5.79483L18.2738 5.17956C18.5651 5.05159 18.9055 5.12136 19.1229 5.35362C20.1351 6.43464 20.8889 7.73115 21.3277 9.14558C21.4223 9.45058 21.3134 9.78203 21.0564 9.9715L19.8149 10.8866C19.4607 11.1468 19.2516 11.56 19.2516 11.9995C19.2516 12.4389 19.4607 12.8521 19.8157 13.1129L21.0582 14.0283C21.3153 14.2177 21.4243 14.5492 21.3297 14.8543C20.8911 16.2685 20.1377 17.5649 19.1261 18.6461C18.9089 18.8783 18.5688 18.9483 18.2775 18.8206L16.8712 18.2045C16.4688 18.0284 16.0068 18.0542 15.6265 18.274C15.2463 18.4937 14.9933 18.8812 14.945 19.3177L14.7759 20.8444C14.741 21.1592 14.5122 21.4182 14.204 21.4915C12.7556 21.8361 11.2465 21.8361 9.79803 21.4915C9.48991 21.4182 9.26105 21.1592 9.22618 20.8444L9.05736 19.32C9.00777 18.8843 8.75434 18.498 8.37442 18.279C7.99451 18.06 7.5332 18.0343 7.1322 18.2094L5.72557 18.8256C5.43422 18.9533 5.09403 18.8833 4.87678 18.6509C3.86462 17.5685 3.11119 16.2705 2.6732 14.8548C2.57886 14.5499 2.68786 14.2186 2.94485 14.0293L4.18818 13.1133C4.54232 12.8531 4.75147 12.4399 4.75147 12.0005C4.75147 11.561 4.54232 11.1478 4.18771 10.8873L2.94516 9.97285C2.6878 9.78345 2.5787 9.45178 2.67337 9.14658C3.11212 7.73215 3.86594 6.43564 4.87813 5.35462C5.09559 5.12236 5.43594 5.05259 5.72724 5.18056L7.12762 5.79572C7.53056 5.97256 7.9938 5.94585 8.37577 5.72269C8.75609 5.50209 9.00929 5.11422 9.05817 4.67764L9.22824 3.15196C9.26376 2.83335 9.49786 2.57254 9.8108 2.50294C10.5281 2.34342 11.26 2.25865 12.0122 2.25ZM12.0124 3.7499C11.5583 3.75524 11.1056 3.79443 10.6578 3.86702L10.5489 4.84418C10.4471 5.75368 9.92003 6.56102 9.13042 7.01903C8.33597 7.48317 7.36736 7.53903 6.52458 7.16917L5.62629 6.77456C5.05436 7.46873 4.59914 8.25135 4.27852 9.09168L5.07632 9.67879C5.81513 10.2216 6.25147 11.0837 6.25147 12.0005C6.25147 12.9172 5.81513 13.7793 5.0771 14.3215L4.27805 14.9102C4.59839 15.752 5.05368 16.5361 5.626 17.2316L6.53113 16.8351C7.36923 16.4692 8.33124 16.5227 9.12353 16.9794C9.91581 17.4361 10.4443 18.2417 10.548 19.1526L10.657 20.1365C11.5466 20.2878 12.4555 20.2878 13.3451 20.1365L13.4541 19.1527C13.5549 18.2421 14.0828 17.4337 14.876 16.9753C15.6692 16.5168 16.6332 16.463 17.4728 16.8305L18.3772 17.2267C18.949 16.5323 19.4041 15.7495 19.7247 14.909L18.9267 14.3211C18.1879 13.7783 17.7516 12.9162 17.7516 11.9995C17.7516 11.0827 18.1879 10.2206 18.9258 9.67847L19.7227 9.09109C19.4021 8.25061 18.9468 7.46784 18.3748 6.77356L17.4783 7.16737C17.113 7.32901 16.7178 7.4122 16.3187 7.41158C14.849 7.41004 13.6155 6.30355 13.4551 4.84383L13.3462 3.8667C12.9007 3.7942 12.4526 3.75512 12.0124 3.7499ZM11.9997 8.24995C14.0708 8.24995 15.7497 9.92888 15.7497 12C15.7497 14.071 14.0708 15.75 11.9997 15.75C9.92863 15.75 8.2497 14.071 8.2497 12C8.2497 9.92888 9.92863 8.24995 11.9997 8.24995ZM11.9997 9.74995C10.7571 9.74995 9.7497 10.7573 9.7497 12C9.7497 13.2426 10.7571 14.25 11.9997 14.25C13.2423 14.25 14.2497 13.2426 14.2497 12C14.2497 10.7573 13.2423 9.74995 11.9997 9.74995Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M3 5.75C3 5.33579 3.33579 5 3.75 5C6.41341 5 9.00797 4.05652 11.55 2.15C11.8167 1.95 12.1833 1.95 12.45 2.15C14.992 4.05652 17.5866 5 20.25 5C20.6642 5 21 5.33579 21 5.75V11C21 16.0012 18.0424 19.6757 12.2749 21.9478C12.0982 22.0174 11.9018 22.0174 11.7251 21.9478C5.95756 19.6757 3 16.0012 3 11V5.75ZM4.5 6.47793V11C4.5 15.2556 6.95337 18.3789 12 20.4419C17.0466 18.3789 19.5 15.2556 19.5 11V6.47793C16.9227 6.32585 14.4192 5.38829 12 3.67782C9.58084 5.38829 7.07735 6.32585 4.5 6.47793Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M3 5.75C3 5.33579 3.33579 5 3.75 5C6.41341 5 9.00797 4.05652 11.55 2.15C11.8167 1.95 12.1833 1.95 12.45 2.15C14.992 4.05652 17.5866 5 20.25 5C20.6642 5 21 5.33579 21 5.75V11C21 11.3381 20.9865 11.6701 20.9595 11.9961C20.5062 11.7106 20.0152 11.4795 19.4955 11.3121C19.4985 11.2087 19.5 11.1047 19.5 11V6.47793C16.9227 6.32585 14.4192 5.38829 12 3.67782C9.58084 5.38829 7.07735 6.32585 4.5 6.47793V11C4.5 15.1488 6.83178 18.2214 11.625 20.2846C11.8882 20.839 12.2276 21.3503 12.6297 21.8048C12.5126 21.8531 12.3944 21.9007 12.2749 21.9478C12.0982 22.0174 11.9018 22.0174 11.7251 21.9478C5.95756 19.6757 3 16.0012 3 11V5.75ZM23 17.5C23 20.5376 20.5376 23 17.5 23C14.4624 23 12 20.5376 12 17.5C12 14.4624 14.4624 12 17.5 12C20.5376 12 23 14.4624 23 17.5ZM20.8536 15.1464C20.6583 14.9512 20.3417 14.9512 20.1464 15.1464L16.5 18.7929L14.8536 17.1464C14.6583 16.9512 14.3417 16.9512 14.1464 17.1464C13.9512 17.3417 13.9512 17.6583 14.1464 17.8536L16.1464 19.8536C16.3417 20.0488 16.6583 20.0488 16.8536 19.8536L20.8536 15.8536C21.0488 15.6583 21.0488 15.3417 20.8536 15.1464Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M4 6C4 5.30945 4.3153 4.70664 4.77423 4.22025C5.2294 3.73784 5.85301 3.33745 6.56668 3.01752C7.99575 2.3769 9.91738 2 12 2C14.0826 2 16.0042 2.3769 17.4333 3.01752C18.147 3.33745 18.7706 3.73784 19.2258 4.22025C19.6847 4.70664 20 5.30945 20 6V18C20 18.6906 19.6847 19.2934 19.2258 19.7798C18.7706 20.2622 18.147 20.6626 17.4333 20.9825C16.0042 21.6231 14.0826 22 12 22C9.91738 22 7.99575 21.6231 6.56668 20.9825C5.85301 20.6626 5.2294 20.2622 4.77423 19.7798C4.3153 19.2934 4 18.6906 4 18V6ZM5.5 6C5.5 6.20691 5.59044 6.45909 5.86525 6.75034C6.14382 7.04559 6.58195 7.3455 7.18027 7.61372C8.37519 8.14937 10.0786 8.5 12 8.5C13.9214 8.5 15.6248 8.14937 16.8197 7.61372C17.418 7.3455 17.8562 7.04559 18.1348 6.75034C18.4096 6.45909 18.5 6.20691 18.5 6C18.5 5.79309 18.4096 5.54091 18.1348 5.24966C17.8562 4.95441 17.418 4.65449 16.8197 4.38628C15.6248 3.85063 13.9214 3.5 12 3.5C10.0786 3.5 8.37519 3.85063 7.18027 4.38628C6.58195 4.65449 6.14382 4.95441 5.86525 5.24966C5.59044 5.54091 5.5 5.79309 5.5 6ZM18.5 8.39242C18.1791 8.61282 17.8194 8.80942 17.4333 8.98248C16.0042 9.6231 14.0826 10 12 10C9.91738 10 7.99575 9.6231 6.56668 8.98248C6.18063 8.80942 5.82094 8.61282 5.5 8.39242V18C5.5 18.2069 5.59044 18.4591 5.86525 18.7503C6.14382 19.0456 6.58195 19.3455 7.18027 19.6137C8.37519 20.1494 10.0786 20.5 12 20.5C13.9214 20.5 15.6248 20.1494 16.8197 19.6137C17.418 19.3455 17.8562 19.0456 18.1348 18.7503C18.4096 18.4591 18.5 18.2069 18.5 18V8.39242Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12 2C12.4142 2 12.75 2.33579 12.75 2.75V4.25C12.75 4.66421 12.4142 5 12 5C11.5858 5 11.25 4.66421 11.25 4.25V2.75C11.25 2.33579 11.5858 2 12 2ZM12 17C14.7614 17 17 14.7614 17 12C17 9.23858 14.7614 7 12 7C9.23858 7 7 9.23858 7 12C7 14.7614 9.23858 17 12 17ZM12 15.5C10.067 15.5 8.5 13.933 8.5 12C8.5 10.067 10.067 8.5 12 8.5C13.933 8.5 15.5 10.067 15.5 12C15.5 13.933 13.933 15.5 12 15.5ZM21.25 12.75C21.6642 12.75 22 12.4142 22 12C22 11.5858 21.6642 11.25 21.25 11.25H19.75C19.3358 11.25 19 11.5858 19 12C19 12.4142 19.3358 12.75 19.75 12.75H21.25ZM12 19C12.4142 19 12.75 19.3358 12.75 19.75V21.25C12.75 21.6642 12.4142 22 12 22C11.5858 22 11.25 21.6642 11.25 21.25V19.75C11.25 19.3358 11.5858 19 12 19ZM4.25 12.75C4.66421 12.75 5 12.4142 5 12C5 11.5858 4.66421 11.25 4.25 11.25H2.75C2.33579 11.25 2 11.5858 2 12C2 12.4142 2.33579 12.75 2.75 12.75H4.25ZM4.21967 4.22004C4.51256 3.92715 4.98744 3.92715 5.28033 4.22004L6.78033 5.72004C7.07322 6.01294 7.07322 6.48781 6.78033 6.7807C6.48744 7.0736 6.01256 7.0736 5.71967 6.7807L4.21967 5.2807C3.92678 4.98781 3.92678 4.51294 4.21967 4.22004ZM5.28033 19.7807C4.98744 20.0736 4.51256 20.0736 4.21967 19.7807C3.92678 19.4878 3.92678 19.0129 4.21967 18.72L5.71967 17.22C6.01256 16.9271 6.48744 16.9271 6.78033 17.22C7.07322 17.5129 7.07322 17.9878 6.78033 18.2807L5.28033 19.7807ZM19.7803 4.22004C19.4874 3.92715 19.0126 3.92715 18.7197 4.22004L17.2197 5.72004C16.9268 6.01294 16.9268 6.48781 17.2197 6.7807C17.5126 7.0736 17.9874 7.0736 18.2803 6.7807L19.7803 5.2807C20.0732 4.98781 20.0732 4.51294 19.7803 4.22004ZM18.7197 19.7807C19.0126 20.0736 19.4874 20.0736 19.7803 19.7807C20.0732 19.4878 20.0732 19.0129 19.7803 18.72L18.2803 17.22C17.9874 16.9271 17.5126 16.9271 17.2197 17.22C16.9268 17.5129 16.9268 17.9878 17.2197 18.2807L18.7197 19.7807Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M16.2506 5.18011C15.9994 5.50947 16.0627 5.9801 16.3921 6.23128C18.1804 7.59515 19.25 9.70821 19.25 12C19.25 15.736 16.4242 18.812 12.7933 19.2071L13.4697 18.5303C13.7626 18.2374 13.7626 17.7626 13.4697 17.4697C13.2034 17.2034 12.7867 17.1792 12.4931 17.3971L12.409 17.4697L10.409 19.4697C10.1427 19.7359 10.1185 20.1526 10.3364 20.4462L10.409 20.5303L12.409 22.5303C12.7019 22.8232 13.1768 22.8232 13.4697 22.5303C13.7359 22.2641 13.7601 21.8474 13.5423 21.5538L13.4697 21.4697L12.7194 20.7208C17.2154 20.355 20.75 16.5903 20.75 12C20.75 9.23526 19.4582 6.68321 17.3017 5.03856C16.9724 4.78738 16.5017 4.85075 16.2506 5.18011ZM10.5303 1.46967C10.2374 1.76256 10.2374 2.23744 10.5303 2.53033L11.2796 3.27923C6.78409 3.6456 3.25 7.41008 3.25 12C3.25 14.6445 4.43126 17.0974 6.43081 18.7491C6.75016 19.0129 7.22289 18.9679 7.48669 18.6485C7.75048 18.3292 7.70545 17.8564 7.3861 17.5926C5.72793 16.2229 4.75 14.1922 4.75 12C4.75 8.26436 7.57532 5.18861 11.2057 4.79301L10.5303 5.46967C10.2374 5.76256 10.2374 6.23744 10.5303 6.53033C10.8232 6.82322 11.2981 6.82322 11.591 6.53033L13.591 4.53033C13.8839 4.23744 13.8839 3.76256 13.591 3.46967L11.591 1.46967C11.2981 1.17678 10.8232 1.17678 10.5303 1.46967Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M6.75 22.0004C6.33579 22.0004 6 21.6647 6 21.2504C6 20.8707 6.28215 20.557 6.64823 20.5073L6.75 20.5004L8.499 20.5V18.002L4.25 18.0023C3.05914 18.0023 2.08436 17.0771 2.00519 15.9063L2 15.7523V5.25C2 4.05914 2.92516 3.08436 4.09595 3.00519L4.25 3H19.7488C20.9397 3 21.9145 3.92516 21.9936 5.09595L21.9988 5.25V15.7523C21.9988 16.9431 21.0737 17.9179 19.9029 17.9971L19.7488 18.0023L15.499 18.002V20.5L17.25 20.5004C17.6642 20.5004 18 20.8362 18 21.2504C18 21.6301 17.7178 21.9439 17.3518 21.9936L17.25 22.0004H6.75ZM13.998 18.002H9.998L9.999 20.5004H13.999L13.998 18.002ZM19.7488 4.5H4.25C3.8703 4.5 3.55651 4.78215 3.50685 5.14823L3.5 5.25V15.7523C3.5 16.132 3.78215 16.4458 4.14823 16.4954L4.25 16.5023H19.7488C20.1285 16.5023 20.4423 16.2201 20.492 15.854L20.4988 15.7523V5.25C20.4988 4.8703 20.2167 4.55651 19.8506 4.50685L19.7488 4.5Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M3.5 12C3.5 7.30558 7.30558 3.5 12 3.5C16.6944 3.5 20.5 7.30558 20.5 12C20.5 16.6944 16.6944 20.5 12 20.5C7.30558 20.5 3.5 16.6944 3.5 12ZM12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2ZM11.9931 6.64827C11.9435 6.28233 11.6295 6 11.25 6C10.836 6 10.5 6.336 10.5 6.75V12.75L10.5069 12.8517C10.5565 13.2177 10.8705 13.5 11.25 13.5H15.25L15.3517 13.4931C15.7177 13.4435 16 13.1295 16 12.75C16 12.336 15.664 12 15.25 12H12V6.75L11.9931 6.64827Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12.7276 2.2155C12.8731 2.07426 13.0696 1.99796 13.2723 2.00399C18.0267 2.1455 21.8545 5.97326 21.996 10.7276C22.002 10.9303 21.9257 11.1268 21.7844 11.2723C21.6432 11.4178 21.4491 11.4999 21.2463 11.4999H13.25C12.8358 11.4999 12.5 11.1642 12.5 10.7499V2.75366C12.5 2.55088 12.5821 2.35674 12.7276 2.2155ZM14 3.566V9.99995H20.434C19.9888 6.65788 17.3421 4.01119 14 3.566ZM11 4.76465C11 4.55403 10.9114 4.35312 10.756 4.21103C10.6005 4.06894 10.3925 3.99877 10.1827 4.01768C5.59476 4.43112 2 8.28584 2 12.981C2 17.9516 6.02944 21.981 11 21.981C15.6881 21.981 19.5383 18.3971 19.9615 13.819C19.9809 13.609 19.9109 13.4005 19.7688 13.2447C19.6267 13.0888 19.4256 13 19.2146 13H13.25C12.0074 13 11 11.9926 11 10.75V4.76465ZM3.5 12.981C3.5 9.35271 6.07693 6.32555 9.5 5.63092V10.75C9.5 12.8211 11.1789 14.5 13.25 14.5H18.3462C17.6443 17.9136 14.6216 20.481 11 20.481C6.85786 20.481 3.5 17.1232 3.5 12.981Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10.25 11C9.83579 11 9.5 11.3358 9.5 11.75C9.5 12.1642 9.83579 12.5 10.25 12.5H13.75C14.1642 12.5 14.5 12.1642 14.5 11.75C14.5 11.3358 14.1642 11 13.75 11H10.25ZM3 5.25C3 4.00736 4.00736 3 5.25 3H18.75C19.9926 3 21 4.00736 21 5.25V6.75C21 7.5301 20.603 8.21748 20 8.62111V17.25C20 19.3211 18.3211 21 16.25 21H7.75C5.67893 21 4 19.3211 4 17.25V8.62111C3.39701 8.21748 3 7.5301 3 6.75V5.25ZM5.5 9V17.25C5.5 18.4926 6.50736 19.5 7.75 19.5H16.25C17.4926 19.5 18.5 18.4926 18.5 17.25V9H5.5ZM5.25 4.5C4.83579 4.5 4.5 4.83579 4.5 5.25V6.75C4.5 7.16421 4.83579 7.5 5.25 7.5H18.75C19.1642 7.5 19.5 7.16421 19.5 6.75V5.25C19.5 4.83579 19.1642 4.5 18.75 4.5H5.25Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M17 13.5C17 12.6716 16.3284 12 15.5 12H8.5C7.67157 12 7 12.6716 7 13.5V14C7 15.9714 8.85951 18 12 18C15.1405 18 17 15.9714 17 14V13.5ZM14.75 8.25C14.75 6.73122 13.5188 5.5 12 5.5C10.4812 5.5 9.25 6.73122 9.25 8.25C9.25 9.76878 10.4812 11 12 11C13.5188 11 14.75 9.76878 14.75 8.25ZM22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12ZM20.5 12C20.5 7.30558 16.6944 3.5 12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 16.6944 7.30558 20.5 12 20.5C16.6944 20.5 20.5 16.6944 20.5 12Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M17.5004 12.0003C20.5379 12.0003 23.0004 14.4627 23.0004 17.5003C23.0004 20.5378 20.5379 23.0003 17.5004 23.0003C14.4628 23.0003 12.0004 20.5378 12.0004 17.5003C12.0004 14.4627 14.4628 12.0003 17.5004 12.0003ZM12.0226 13.9996C11.7259 14.4629 11.4864 14.9663 11.314 15.4999L4.25391 15.5002C3.83969 15.5002 3.50391 15.836 3.50391 16.2502V17.1575C3.50391 17.8132 3.7899 18.4362 4.28707 18.8636C5.54516 19.9453 7.44117 20.5013 10.0004 20.5013C10.5992 20.5013 11.1618 20.4709 11.6885 20.4104C11.9374 20.9105 12.2512 21.3743 12.6175 21.7908C11.8153 21.9315 10.9423 22.0013 10.0004 22.0013C7.11087 22.0013 4.87205 21.3447 3.30918 20.001C2.48056 19.2887 2.00391 18.2503 2.00391 17.1575V16.2502C2.00391 15.0075 3.01127 14.0002 4.25391 14.0002L12.0226 13.9996ZM20.8096 15.2525L15.2526 20.8095C15.8932 21.2454 16.667 21.5003 17.5004 21.5003C19.7095 21.5003 21.5004 19.7094 21.5004 17.5003C21.5004 16.6669 21.2455 15.8931 20.8096 15.2525ZM17.5004 13.5003C15.2912 13.5003 13.5004 15.2911 13.5004 17.5003C13.5004 18.3336 13.7552 19.1074 14.1912 19.748L19.7481 14.1911C19.1075 13.7551 18.3337 13.5003 17.5004 13.5003ZM10.0004 2.00488C12.7618 2.00488 15.0004 4.24346 15.0004 7.00488C15.0004 9.76631 12.7618 12.0049 10.0004 12.0049C7.23894 12.0049 5.00036 9.76631 5.00036 7.00488C5.00036 4.24346 7.23894 2.00488 10.0004 2.00488ZM10.0004 3.50488C8.06737 3.50488 6.50036 5.07189 6.50036 7.00488C6.50036 8.93788 8.06737 10.5049 10.0004 10.5049C11.9334 10.5049 13.5004 8.93788 13.5004 7.00488C13.5004 5.07189 11.9334 3.50488 10.0004 3.50488Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M5 12h14" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 5v14" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="m12 19-7-7 7-7" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M19 12H5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10.268 21a2 2 0 0 0 3.464 0" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,15 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12 20v-9" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M14.12 3.88 16 2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M21 21a4 4 0 0 0-3.81-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M21 5a4 4 0 0 1-3.55 3.97" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M22 13h-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M3 21a4 4 0 0 1 3.81-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M3 5a4 4 0 0 0 3.55 3.97" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M6 13H2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="m8 2 1.88 1.88" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M9 7.13V6a3 3 0 1 1 6 0v1.13" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M20 6 9 17l-5-5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="m9 18 6-6-6-6" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M18 6 6 18" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="m6 6 12 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10.94 5.274A7 7 0 0 1 15.71 10h1.79a4.5 4.5 0 0 1 4.222 6.057" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M18.796 18.81A4.5 4.5 0 0 1 17.5 19H9A7 7 0 0 1 5.79 5.78" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="m2 2 20 20" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="m18 16 4-4-4-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="m6 8-4 4 4 4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="m14.5 4-5 16" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10 11v6" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M14 11v6" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M3 6h18" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M14 2v5a1 1 0 0 0 1 1h5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M10 9H8" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M16 13H8" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M16 17H8" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12 15V3" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="m7 10 5 5 5-5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M14 2v5a1 1 0 0 0 1 1h5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 22 12 A 10 10 0 1 0 2 12 A 10 10 0 1 0 22 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M2 12h20" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 22 12 A 10 10 0 1 0 2 12 A 10 10 0 1 0 22 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 16v-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 8h.01" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 5 11 H 19 A 2 2 0 0 1 21 13 V 20 A 2 2 0 0 1 19 22 H 5 A 2 2 0 0 1 3 20 V 13 A 2 2 0 0 1 5 11 Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M7 11V7a5 5 0 0 1 10 0v4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M8 6v8" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M6 8.32a7.43 7.43 0 0 1 0 7.36" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M9.46 6.21a11.76 11.76 0 0 1 0 11.58" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12.91 4.1a15.91 15.91 0 0 1 .01 15.8" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M16.37 2a20.16 20.16 0 0 1 0 20" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,16 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 4 3 H 7 A 1 1 0 0 1 8 4 V 7 A 1 1 0 0 1 7 8 H 4 A 1 1 0 0 1 3 7 V 4 A 1 1 0 0 1 4 3 Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 17 3 H 20 A 1 1 0 0 1 21 4 V 7 A 1 1 0 0 1 20 8 H 17 A 1 1 0 0 1 16 7 V 4 A 1 1 0 0 1 17 3 Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 4 16 H 7 A 1 1 0 0 1 8 17 V 20 A 1 1 0 0 1 7 21 H 4 A 1 1 0 0 1 3 20 V 17 A 1 1 0 0 1 4 16 Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M21 16h-3a2 2 0 0 0-2 2v3" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M21 21v.01" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 7v3a2 2 0 0 1-2 2H7" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M3 12h.01" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 3h.01" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 16v.01" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M16 12h1" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M21 12v.01" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M12 21v-1" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,11 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M4.9 16.1C1 12.2 1 5.8 4.9 1.9" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 14 9 A 2 2 0 1 0 10 9 A 2 2 0 1 0 14 9" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M16.2 4.8c2 2 2.26 5.11.8 7.47" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M19.1 1.9a9.96 9.96 0 0 1 0 14.1" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M9.5 18h5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="m8 22 4-11 4 11" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M3 7V5a2 2 0 0 1 2-2h2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M17 3h2a2 2 0 0 1 2 2v2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M21 17v2a2 2 0 0 1-2 2h-2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M7 21H5a2 2 0 0 1-2-2v-2" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M7 12h10" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

Some files were not shown because too many files have changed in this diff Show More