diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/104.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/104.json new file mode 100644 index 000000000000..a15494cac93e --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/104.json @@ -0,0 +1,1328 @@ +{ + "formatVersion": 1, + "database": { + "version": 104, + "identityHash": "a9bbb2da1d29e922c8f721586ecd3758", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `governance` INTEGER, `forbidden_filename_characters` TEXT, `forbidden_filenames` TEXT, `forbidden_filename_extensions` TEXT, `forbidden_filename_basenames` TEXT, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER, `client_integration_json` TEXT, `mod_rewrite_working` INTEGER, `chunked_upload_max_size` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "governance", + "columnName": "governance", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "TEXT" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientIntegrationJson", + "columnName": "client_integration_json", + "affinity": "TEXT" + }, + { + "fieldPath": "modRewriteWorking", + "columnName": "mod_rewrite_working", + "affinity": "INTEGER" + }, + { + "fieldPath": "chunkedUploadMaxSize", + "columnName": "chunked_upload_max_size", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER, `is_read_only` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `upload_end_timestamp_long` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestampLong", + "columnName": "upload_end_timestamp_long", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a9bbb2da1d29e922c8f721586ecd3758')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt b/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt index 632de0e1c459..c0a0cdfbdcc5 100644 --- a/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/files/FileMenuFilterIT.kt @@ -69,6 +69,7 @@ class FileMenuFilterIT : AbstractIT() { every { mockComponentsGetter.operationsServiceBinder } returns mockOperationsServiceBinder every { mockStorageManager.getFileById(any()) } returns OCFile("/") every { mockStorageManager.getFolderContent(any(), any()) } returns ArrayList() + every { mockStorageManager.isReadOnly(any()) } returns false every { mockArbitraryDataProvider.getValue(any(), any()) } returns "" editorUtils = EditorUtils(mockArbitraryDataProvider) } @@ -363,6 +364,63 @@ class FileMenuFilterIT : AbstractIT() { } } + @Test + fun filter_readOnlyFile_hidesModifyingActions() { + configureCapability( + OCCapability().apply { + endToEndEncryption = CapabilityBooleanType.TRUE + filesLockingVersion = "1.0" + } + ) + + every { mockStorageManager.isReadOnly(any()) } returns true + + val file = OCFile("/readOnly.txt").apply { + permissions = FULL_PERMISSIONS + } + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + val toHide = filterFactory + .newInstance(file, mockComponentsGetter, true, user) + .getToHide(false) + + READ_ONLY_HIDDEN_ACTIONS.forEach { action -> + assertTrue(toHide.contains(action)) + } + } + } + } + + @Test + fun filter_writableFile_keepsModifyingActions() { + configureCapability( + OCCapability().apply { + filesLockingVersion = "1.0" + } + ) + + val file = OCFile("/writable.txt").apply { + permissions = FULL_PERMISSIONS + } + + launchActivity().use { + it.onActivity { activity -> + val filterFactory = FileMenuFilter.Factory(mockStorageManager, activity, editorUtils) + + val toHide = filterFactory + .newInstance(file, mockComponentsGetter, true, user) + .getToHide(false) + + WRITABLE_VISIBLE_ACTIONS.forEach { action -> + assertFalse(toHide.contains(action)) + } + } + } + } + private data class ExpectedLockVisibilities(val lockFile: Boolean, val unlockFile: Boolean) private fun configureCapability(capability: OCCapability) { @@ -398,6 +456,29 @@ class FileMenuFilterIT : AbstractIT() { } companion object { + private const val FULL_PERMISSIONS = "RGDNVW" + + private val READ_ONLY_HIDDEN_ACTIONS = listOf( + R.id.action_remove_file, + R.id.action_rename_file, + R.id.action_move_or_copy, + R.id.action_edit, + R.id.action_encrypted, + R.id.action_unset_encrypted, + R.id.action_lock_file, + R.id.action_unlock_file, + R.id.action_favorite, + R.id.action_unset_favorite + ) + + private val WRITABLE_VISIBLE_ACTIONS = listOf( + R.id.action_remove_file, + R.id.action_rename_file, + R.id.action_move_or_copy, + R.id.action_lock_file, + R.id.action_favorite + ) + private const val OFFICE_MIMETYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" diff --git a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt index bd196e84da1c..47d27b0558ce 100644 --- a/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt +++ b/app/src/androidTest/java/com/owncloud/android/utils/EncryptionUtilsV2IT.kt @@ -28,6 +28,7 @@ import com.owncloud.android.operations.RefreshFolderOperation import com.owncloud.android.util.EncryptionTestIT import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertTrue +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Test @@ -645,76 +646,187 @@ class EncryptionUtilsV2IT : EncryptionIT() { assertEquals("this is a test.\n", gunzip) } -// @Test -// fun validate() { -// // ALEX -// val metadata1 = """{ -// "metadata": { -// "authenticationTag": "zMozev5R09UopLrq7Je1lw==", -// "ciphertext": "j0OBtUrEt4IveGiexjmGK7eKEaWrY70ZkteA5KxHDaZT/t2wwGy9j2FPQGpqXnW6OO3iAYPNgwFikI1smnfNvqdxzVDvhavl/IXa9Kg2niWyqK3D9zpz0YD6mDvl0XsOgTNVyGXNVREdWgzGEERCQoyHI1xowt/swe3KCXw+lf+XPF/t1PfHv0DiDVk70AeWGpPPPu6yggAIxB4Az6PEZhaQWweTC0an48l2FHj2MtB2PiMHtW2v7RMuE8Al3PtE4gOA8CMFrB+Npy6rKcFCXOgTZm5bp7q+J1qkhBDbiBYtvdsYujJ52Xa5SifTpEhGeWWLFnLLgPAQ8o6bXcWOyCoYfLfp4Jpft/Y7H8qzHbPewNSyD6maEv+xljjfU7hxibbszz5A4JjMdQy2BDGoTmJx7Mas+g6l6ZuHLVbdmgQOvD3waJBy6rOg0euux0Cn4bB4bIFEF2KvbhdGbY1Uiq9DYa7kEmSEnlcAYaHyroTkDg4ew7ER0vIBBMzKM3r+UdPVKKS66uyXtZc=", -// "nonce": "W+lxQJeGq7XAJiGfcDohkg==" -// }, -// "users": [{ -// "certificate": "-----BEGIN CERTIFICATE-----\nMIIDkDCCAnigAwIBAgIBADANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQGEwJERTEb\nMBkGA1UECAwSQmFkZW4tV3VlcnR0ZW1iZXJnMRIwEAYDVQQHDAlTdHV0dGdhcnQx\nEjAQBgNVBAoMCU5leHRjbG91ZDENMAsGA1UEAwwEam9objAeFw0yMzA3MTQwNzM0\nNTZaFw00MzA3MDkwNzM0NTZaMGExCzAJBgNVBAYTAkRFMRswGQYDVQQIDBJCYWRl\nbi1XdWVydHRlbWJlcmcxEjAQBgNVBAcMCVN0dXR0Z2FydDESMBAGA1UECgwJTmV4\ndGNsb3VkMQ0wCwYDVQQDDARqb2huMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\nCgKCAQEA7j3Er5YahJT0LAnSRLhpqbRo+E1AVnt98rvp3DmEfBHNzNB+DS9IBDkS\nSXM/YtfAci6Tcw8ujVBjrZX/WEmrf8ynQHxYmSaJSnP8uAT306/MceZpdpruEc9/\nS10a7vp54Zbld4NYdmfS71oVFVKgM7c/Vthx+rgu48fuxzbWAvVYLFcx47hz0DJT\nnjz2Za/R68uXpxfz7J9uEXYiqsAs/FobDsLZluT3RyywVRwKBed1EZxUeLIJiyxp\nUthhGfIb8b3Vf9jZoUVi3m5gmc4spJQHvYAkfZYHzd9ras8jBu1abQRxcu2CYnVo\n6Y0mTxhKhQS/n5gjv3ExiQF3wp/XYwIDAQABo1MwUTAdBgNVHQ4EFgQUmTeILVuB\ntv70fTGkXWGAueDp5kAwHwYDVR0jBBgwFoAUmTeILVuBtv70fTGkXWGAueDp5kAw\nDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAyVtq9XAvW7nxSW/8\nhp30z6xbzGiuviXhy/Jo91VEa8IRsWCCn3OmDFiVduTEowx76tf8clJP0gk7Pozi\n6dg/7Fin+FqQGXfCk8bLAh9gXKAikQ2GK8yRN3slRFwYC2mm23HrLdKXZHUqJcpB\nMz2zsSrOGPj1YsYOl/U8FU6KA7Yj7U3q7kDMYTAgzUPZAH+d1DISGWpZsMa0RYid\nvigCCLByiccmS/Co4Sb1esF58H+YtV5+nFBRwx881U2g2TgDKF1lPMK/y3d8B8mh\nUtW+lFxRpvyNUDpsMjOErOrtNFEYbgoUJLtqwBMmyGR+nmmh6xna331QWcRAmw0P\nnDO4ew==\n-----END CERTIFICATE-----\n", -// "encryptedMetadataKey": "HVT49bYmwXbGs/dJ2avgU9unrKnPf03MYUI5ZysSR1Bz5pqz64gzH2GBAuUJ+Q4VmHtEfcMaWW7VXgzfCQv5xLBrk+RSgcLOKnlIya8jaDlfttWxbe8jJK+/0+QVPOc6ycA/t5HNCPg09hzj+gnb2L89UHxL5accZD0iEzb5cQbGrc/N6GthjgGrgFKtFf0HhDVplUr+DL9aTyKuKLBPjrjuZbv8M6ZfXO93mOMwSZH3c3rwDUHb/KEaTR/Og4pWQmrqr1VxGLqeV/+GKWhzMYThrOZAUz+5gsbckU2M5V9i+ph0yBI5BjOZVhNuDwW8yP8WtyRJwQc+UBRei/RGBQ==", -// "userId": "john" -// }], -// "version": "2" -// } -// -// """ -// -// val signature1 = -// "ewogICAgIm1ldGFkYXRhIjogewogICAgICAgICJhdXRoZW50aWNhdGlvblRhZyI6ICJ6TW96ZXY1UjA5VW9wTHJxN0plMWx3PT0iLAogICAgICAgICJjaXBoZXJ0ZXh0IjogImowT0J0VXJFdDRJdmVHaWV4am1HSzdlS0VhV3JZNzBaa3RlQTVLeEhEYVpUL3Qyd3dHeTlqMkZQUUdwcVhuVzZPTzNpQVlQTmd3RmlrSTFzbW5mTnZxZHh6VkR2aGF2bC9JWGE5S2cybmlXeXFLM0Q5enB6MFlENm1EdmwwWHNPZ1ROVnlHWE5WUkVkV2d6R0VFUkNRb3lISTF4b3d0L3N3ZTNLQ1h3K2xmK1hQRi90MVBmSHYwRGlEVms3MEFlV0dwUFBQdTZ5Z2dBSXhCNEF6NlBFWmhhUVd3ZVRDMGFuNDhsMkZIajJNdEIyUGlNSHRXMnY3Uk11RThBbDNQdEU0Z09BOENNRnJCK05weTZyS2NGQ1hPZ1RabTVicDdxK0oxcWtoQkRiaUJZdHZkc1l1ako1MlhhNVNpZlRwRWhHZVdXTEZuTExnUEFROG82YlhjV095Q29ZZkxmcDRKcGZ0L1k3SDhxekhiUGV3TlN5RDZtYUV2K3hsampmVTdoeGliYnN6ejVBNEpqTWRReTJCREdvVG1KeDdNYXMrZzZsNlp1SExWYmRtZ1FPdkQzd2FKQnk2ck9nMGV1dXgwQ240YkI0YklGRUYyS3ZiaGRHYlkxVWlxOURZYTdrRW1TRW5sY0FZYUh5cm9Ua0RnNGV3N0VSMHZJQkJNektNM3IrVWRQVktLUzY2dXlYdFpjPSIsCiAgICAgICAgIm5vbmNlIjogIlcrbHhRSmVHcTdYQUppR2ZjRG9oa2c9PSIKICAgIH0sCiAgICAidXNlcnMiOiB7CiAgICAgICAgImNlcnRpZmljYXRlIjogIi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLVxuTUlJRGtEQ0NBbmlnQXdJQkFnSUJBREFOQmdrcWhraUc5dzBCQVFVRkFEQmhNUXN3Q1FZRFZRUUdFd0pFUlRFYlxuTUJrR0ExVUVDQXdTUW1Ga1pXNHRWM1ZsY25SMFpXMWlaWEpuTVJJd0VBWURWUVFIREFsVGRIVjBkR2RoY25ReFxuRWpBUUJnTlZCQW9NQ1U1bGVIUmpiRzkxWkRFTk1Bc0dBMVVFQXd3RWFtOW9iakFlRncweU16QTNNVFF3TnpNMFxuTlRaYUZ3MDBNekEzTURrd056TTBOVFphTUdFeEN6QUpCZ05WQkFZVEFrUkZNUnN3R1FZRFZRUUlEQkpDWVdSbFxuYmkxWGRXVnlkSFJsYldKbGNtY3hFakFRQmdOVkJBY01DVk4wZFhSMFoyRnlkREVTTUJBR0ExVUVDZ3dKVG1WNFxuZEdOc2IzVmtNUTB3Q3dZRFZRUUREQVJxYjJodU1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQlxuQ2dLQ0FRRUE3ajNFcjVZYWhKVDBMQW5TUkxocHFiUm8rRTFBVm50OThydnAzRG1FZkJITnpOQitEUzlJQkRrU1xuU1hNL1l0ZkFjaTZUY3c4dWpWQmpyWlgvV0VtcmY4eW5RSHhZbVNhSlNuUDh1QVQzMDYvTWNlWnBkcHJ1RWM5L1xuUzEwYTd2cDU0WmJsZDROWWRtZlM3MW9WRlZLZ003Yy9WdGh4K3JndTQ4ZnV4emJXQXZWWUxGY3g0N2h6MERKVFxubmp6MlphL1I2OHVYcHhmejdKOXVFWFlpcXNBcy9Gb2JEc0xabHVUM1J5eXdWUndLQmVkMUVaeFVlTElKaXl4cFxuVXRoaEdmSWI4YjNWZjlqWm9VVmkzbTVnbWM0c3BKUUh2WUFrZlpZSHpkOXJhczhqQnUxYWJRUnhjdTJDWW5Wb1xuNlkwbVR4aEtoUVMvbjVnanYzRXhpUUYzd3AvWFl3SURBUUFCbzFNd1VUQWRCZ05WSFE0RUZnUVVtVGVJTFZ1QlxudHY3MGZUR2tYV0dBdWVEcDVrQXdId1lEVlIwakJCZ3dGb0FVbVRlSUxWdUJ0djcwZlRHa1hXR0F1ZURwNWtBd1xuRHdZRFZSMFRBUUgvQkFVd0F3RUIvekFOQmdrcWhraUc5dzBCQVFVRkFBT0NBUUVBeVZ0cTlYQXZXN254U1cvOFxuaHAzMHo2eGJ6R2l1dmlYaHkvSm85MVZFYThJUnNXQ0NuM09tREZpVmR1VEVvd3g3NnRmOGNsSlAwZ2s3UG96aVxuNmRnLzdGaW4rRnFRR1hmQ2s4YkxBaDlnWEtBaWtRMkdLOHlSTjNzbFJGd1lDMm1tMjNIckxkS1haSFVxSmNwQlxuTXoyenNTck9HUGoxWXNZT2wvVThGVTZLQTdZajdVM3E3a0RNWVRBZ3pVUFpBSCtkMURJU0dXcFpzTWEwUllpZFxudmlnQ0NMQnlpY2NtUy9DbzRTYjFlc0Y1OEgrWXRWNStuRkJSd3g4ODFVMmcyVGdES0YxbFBNSy95M2Q4QjhtaFxuVXRXK2xGeFJwdnlOVURwc01qT0VyT3J0TkZFWWJnb1VKTHRxd0JNbXlHUitubW1oNnhuYTMzMVFXY1JBbXcwUFxubkRPNGV3PT1cbi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS1cbiIsCiAgICAgICAgImVuY3J5cHRlZE1ldGFkYXRhS2V5IjogIkhWVDQ5Ylltd1hiR3MvZEoyYXZnVTl1bnJLblBmMDNNWVVJNVp5c1NSMUJ6NXBxejY0Z3pIMkdCQXVVSitRNFZtSHRFZmNNYVdXN1ZYZ3pmQ1F2NXhMQnJrK1JTZ2NMT0tubEl5YThqYURsZnR0V3hiZThqSksrLzArUVZQT2M2eWNBL3Q1SE5DUGcwOWh6aitnbmIyTDg5VUh4TDVhY2NaRDBpRXpiNWNRYkdyYy9ONkd0aGpnR3JnRkt0RmYwSGhEVnBsVXIrREw5YVR5S3VLTEJQanJqdVpidjhNNlpmWE85M21PTXdTWkgzYzNyd0RVSGIvS0VhVFIvT2c0cFdRbXJxcjFWeEdMcWVWLytHS1doek1ZVGhyT1pBVXorNWdzYmNrVTJNNVY5aStwaDB5Qkk1QmpPWlZoTnVEd1c4eVA4V3R5Ukp3UWMrVUJSZWkvUkdCUT09IiwKICAgICAgICAidXNlcklkIjogImpvaG4iCiAgICB9LAogICAgInZlcnNpb24iOiAiMiIKfQo=" -// -// // TOBI -// val metadata = -// """{"metadata":{"authenticationTag":"qDcJnAAGtGDlHWiQMBfXgw\u003d\u003d","ciphertext":"3zUhwIgJWMB7DvrbsDaMvh8MbJdoTxL0OMPCCdYSfBt7gB+V/hwqelL1IOaLto3avhHGSebnrotF06iEP/jZwWg9hApIPTHc8B4XTOY0/kezqYyVqTyquTUZpDpqgVAheQskZZ8I4Ir0seajUkt4KtVRfzO6v8CePRrEg6uKwdYsqDcJnAAGtGDlHWiQMBfXgw\u003d\u003d|4hbOyn1ykQL+9D6SnPY3cQ\u003d\u003d","nonce":"4hbOyn1ykQL+9D6SnPY3cQ\u003d\u003d"},"users":[{"certificate":"-----BEGIN CERTIFICATE-----\nMIIC6DCCAdCgAwIBAgIBADANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJ0MTAe\nFw0yMzA3MjUwNzU3MTJaFw00MzA3MjAwNzU3MTJaMA0xCzAJBgNVBAMMAnQxMIIB\nIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtafHmDBcBqIu4HmMxMDW3j0S\ny+S0YaKwHnBRt85KSwcEov0B5FOLuLknoBGx4Dn3u93ilThXXxacPMHeXL7WPuAs\n21/G7vsqwvrRRnCduf+FUO/AZeDCNErzpsQ8LmTa4PUloLPUcImpSjrHwhMs9Ekv\nEbLRjbeSmSp9XvM+1fV/3jkT5jkOSnCFx5TGwGN5uHqwUir4UWXasvg253NK2XmW\nipKCDCR9TmH1baP3pNdoiChdmErT1c6E4DbBXpTw8XgP5ZbYH+qg1UQ/hC8nRJ3D\nyCcHL+dg/GYraBMhDn4w2Vvq77xNNoNWQ9cT5Ay6cJbQLBQoJQirygQFrobYRQID\nAQABo1MwUTAdBgNVHQ4EFgQUE9zCeA9/QMAtVgLxD23X6ZcodhMwHwYDVR0jBBgw\nFoAUE9zCeA9/QMAtVgLxD23X6ZcodhMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG\n9w0BAQUFAAOCAQEAZdy/YjJlvnz3FQwxp6oVtMJccpdxveEPfLzgaverhtd/vP8O\nAvDzOLgQJHmrDS91SG503eU4cYGyuNKwd77OyTnqMg+GUEmJhGfPpSVrEIdh65jv\nq61T4oqBdehevVmBq54rGiwL0DGv1DlXQlwiJZP4qni2KnOEFcnvL3gVtRnQjXQ+\nkHvlMshkK6w021EMV5NfjG2zg67wC65rLaej5f6Ssp2S7g2VtmE4aXq1bjAuEbqk\n4TiyZHLDdsJuqzyGyyOpMV7i9ucXDoaZt9cGS9hT2vRxTrSH63vKR8Xeig9+stLw\nt9ONcUqCKP7hd8rajtxM4JIIRExwD8OkgARWGg\u003d\u003d\n-----END CERTIFICATE-----\n","encryptedMetadataKey":"s4kDkkLpk1mSmXedP7huiCNC4DYmDAmA2VYGem5M8jIGPC6miVQoo4WXZrEBhdsLw7Msf5iT3A3fTaHhwsI8Jf4McsFyM9/FXT1mCEaGOEpNjbKOlJY1uPUFNOhLqUfFiBos6oBT53hWwoXWjytYvLBbXuXY5YLOysjgBh6URrgFUZAJAmcOJ6OFKgfIIthoqkQc7CQUY97VsRzAXzeYTANBc2yW1pSN51HqftvMzvewFRsJQLcu7a9NjpTdG9LiLhn5eLXOLymXEE/aaPHKXeprlXLzrdWU1xwZRJqV+to2FEiH6CQNsO4+9h5m0VjXekiNeAFrsXB5cJgUipGuzQ\u003d\u003d","userId":"t1"}],"version":"2.0"}""" -// -// val base = EncryptionUtils.encodeStringToBase64String(metadata) -// -// val signature = -// "MIAGCSqGSIb3DQEHAqCAMIACAQExDTALBglghkgBZQMEAgEwCwYJKoZIhvcNAQcBoIAwggLoMIIB0KADAgECAgEAMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAMMAnQxMB4XDTIzMDcyNTA3NTcxMloXDTQzMDcyMDA3NTcxMlowDTELMAkGA1UEAwwCdDEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC1p8eYMFwGoi7geYzEwNbePRLL5LRhorAecFG3zkpLBwSi/QHkU4u4uSegEbHgOfe73eKVOFdfFpw8wd5cvtY+4CzbX8bu+yrC+tFGcJ25/4VQ78Bl4MI0SvOmxDwuZNrg9SWgs9RwialKOsfCEyz0SS8RstGNt5KZKn1e8z7V9X/eORPmOQ5KcIXHlMbAY3m4erBSKvhRZdqy+Dbnc0rZeZaKkoIMJH1OYfVto/ek12iIKF2YStPVzoTgNsFelPDxeA/lltgf6qDVRD+ELydEncPIJwcv52D8ZitoEyEOfjDZW+rvvE02g1ZD1xPkDLpwltAsFCglCKvKBAWuhthFAgMBAAGjUzBRMB0GA1UdDgQWBBQT3MJ4D39AwC1WAvEPbdfplyh2EzAfBgNVHSMEGDAWgBQT3MJ4D39AwC1WAvEPbdfplyh2EzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBl3L9iMmW+fPcVDDGnqhW0wlxyl3G94Q98vOBq96uG13+8/w4C8PM4uBAkeasNL3VIbnTd5ThxgbK40rB3vs7JOeoyD4ZQSYmEZ8+lJWsQh2HrmO+rrVPiioF16F69WYGrnisaLAvQMa/UOVdCXCIlk/iqeLYqc4QVye8veBW1GdCNdD6Qe+UyyGQrrDTbUQxXk1+MbbODrvALrmstp6Pl/pKynZLuDZW2YThperVuMC4RuqThOLJkcsN2wm6rPIbLI6kxXuL25xcOhpm31wZL2FPa9HFOtIfre8pHxd6KD36y0vC3041xSoIo/uF3ytqO3EzgkghETHAPw6SABFYaAAAxggHUMIIB0AIBATASMA0xCzAJBgNVBAMMAnQxAgEAMAsGCWCGSAFlAwQCAaCBljAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yMzA3MjgwNzMwMTJaMCsGCSqGSIb3DQEJNDEeMBwwCwYJYIZIAWUDBAIBoQ0GCSqGSIb3DQEBCwUAMC8GCSqGSIb3DQEJBDEiBCAx7RTJg7hbY5Mkzjw3f6qhX7k/J0FdVz2cL3ow0AmyYjANBgkqhkiG9w0BAQsFAASCAQAbUmb9e7eoIcPNzDSmnzbrueBzgT8YszNGEI+1YCq8XdWN4kDztvP1ZNV21VCO6BvcbfUAnXXgcX5BPeLZNsgXPj3c8TbD59GQl3oT/tIchgMsA20RdAtIwvItlZKh+X6sp0OHkRPYSk/mEYKCKPqrKdJicRWex8ItCwpDR91KSOiKJrN/+DKOGG0sVI9gjzbtrHsN8HmVKxOoNV+wwipcLsWsEmuV+wvPCQ9HJidLX9Q17Bgfc+qJg19aB6iKLWPhjgnfpKGbK5VJuQTdDWPUJ2O4G3W/iwxJ0hAJ7tks4zIATmgGzhgTWYx5LVXbKcuL04xhIOjqwedHeCSBZSSaAAAAAAAA" -// -// val metadataFile = EncryptionUtils.deserializeJSON( -// metadata, -// object : TypeToken() {} -// ) -// assertNotNull(metadataFile) -// -// val certJohnString = metadataFile.users[0].certificate -// val certJohn = EncryptionUtils.convertCertFromString(certJohnString) -// -// val t1String = """-----BEGIN CERTIFICATE----- -// MIIC6DCCAdCgAwIBAgIBADANBgkqhkiG9w0BAQUFADANMQswCQYDVQQDDAJ0MTAe -// Fw0yMzA3MjUwNzU3MTJaFw00MzA3MjAwNzU3MTJaMA0xCzAJBgNVBAMMAnQxMIIB -// IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtafHmDBcBqIu4HmMxMDW3j0S -// y+S0YaKwHnBRt85KSwcEov0B5FOLuLknoBGx4Dn3u93ilThXXxacPMHeXL7WPuAs -// 21/G7vsqwvrRRnCduf+FUO/AZeDCNErzpsQ8LmTa4PUloLPUcImpSjrHwhMs9Ekv -// EbLRjbeSmSp9XvM+1fV/3jkT5jkOSnCFx5TGwGN5uHqwUir4UWXasvg253NK2XmW -// ipKCDCR9TmH1baP3pNdoiChdmErT1c6E4DbBXpTw8XgP5ZbYH+qg1UQ/hC8nRJ3D -// yCcHL+dg/GYraBMhDn4w2Vvq77xNNoNWQ9cT5Ay6cJbQLBQoJQirygQFrobYRQID -// AQABo1MwUTAdBgNVHQ4EFgQUE9zCeA9/QMAtVgLxD23X6ZcodhMwHwYDVR0jBBgw -// FoAUE9zCeA9/QMAtVgLxD23X6ZcodhMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG -// 9w0BAQUFAAOCAQEAZdy/YjJlvnz3FQwxp6oVtMJccpdxveEPfLzgaverhtd/vP8O -// AvDzOLgQJHmrDS91SG503eU4cYGyuNKwd77OyTnqMg+GUEmJhGfPpSVrEIdh65jv -// q61T4oqBdehevVmBq54rGiwL0DGv1DlXQlwiJZP4qni2KnOEFcnvL3gVtRnQjXQ+ -// kHvlMshkK6w021EMV5NfjG2zg67wC65rLaej5f6Ssp2S7g2VtmE4aXq1bjAuEbqk -// 4TiyZHLDdsJuqzyGyyOpMV7i9ucXDoaZt9cGS9hT2vRxTrSH63vKR8Xeig9+stLw -// t9ONcUqCKP7hd8rajtxM4JIIRExwD8OkgARWGg== -// -----END CERTIFICATE-----""" -// -// val t1cert = EncryptionUtils.convertCertFromString(t1String) -// val t1PrivateKeyKey = EncryptionUtils.PEMtoPrivateKey(encryptionTestUtils.t1PrivateKey) -// -// // val signed = encryptionUtilsV2.getMessageSignature( -// // t1cert, -// // t1PrivateKeyKey, -// // metadataFile -// // ) -// -// assertTrue(encryptionUtilsV2.verifySignedMessage(signature1, metadata1, listOf(certJohn, t1cert))) -// } + @Test + fun verifyMetadataEmptyFolder() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + + val metadataKey = EncryptionUtils.generateKey() + val metadata = DecryptedMetadata( + mutableListOf(), + false, + 0, + mutableMapOf(), + mutableMapOf(), + metadataKey + ) + // checksum must be present before encrypting, otherwise verifyMetadata returns false + metadata.keyChecksums.add(encryptionUtilsV2.hashMetadataKey(metadataKey)) + + val metadataFile = DecryptedFolderMetadataFile( + metadata, + mutableListOf(DecryptedUser(enc1.accountName, enc1Cert, null)), + mutableMapOf(), + E2EVersion.V2_1.value + ) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertTrue(result) + assertEquals(0, metadataFile.metadata.files.size) + assertEquals(0, metadataFile.metadata.folders.size) + assertEquals(0, metadataFile.metadata.counter) + } + + @Test + fun verifyMetadataWithOneFile() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + + val metadata = DecryptedMetadata( + mutableListOf(), + false, + 1, + mutableMapOf(), + mutableMapOf( + Pair( + EncryptionUtils.generateUid(), + DecryptedFile( + "document.pdf", + "application/pdf", + "initializationVector", + "authenticationTag", + "key1" + ) + ) + ), + EncryptionUtils.generateKey() + ) + metadata.keyChecksums.add(encryptionUtilsV2.hashMetadataKey(metadata.metadataKey)) + + val metadataFile = DecryptedFolderMetadataFile( + metadata, + mutableListOf(DecryptedUser(enc1.accountName, enc1Cert, null)), + mutableMapOf(), + E2EVersion.V2_1.value + ) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertTrue(result) + assertEquals(1, metadataFile.metadata.files.size) + assertEquals("document.pdf", metadataFile.metadata.files.values.first().filename) + } + + @Test + fun verifyMetadataCounterTooOld() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + // counter in metadata is 1, passing oldCounter=2 should fail + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 2, signature) + + assertFalse(result) + } + + @Test + fun verifyMetadataInvalidSignature() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val tampered = encrypted.copy( + metadata = encrypted.metadata.copy(ciphertext = encrypted.metadata.ciphertext.reversed()) + ) + + val result = encryptionUtilsV2.verifyMetadata(tampered, metadataFile, 0, signature) + + assertFalse(result) + } + + @Test + fun verifyMetadataChecksumMismatch() { + val folder = OCFile("/e/") + val enc1 = MockUser("enc1", "Nextcloud") + val metadataFile = generateDecryptedFolderMetadataFile(enc1, enc1Cert) + + val encrypted = encryptionUtilsV2.encryptFolderMetadataFile( + metadataFile, + enc1UserId, + folder, + storageManager, + client, + enc1PrivateKey, + user, + targetContext, + arbitraryDataProvider + ) + + // Replace the metadata key with a new one whose hash is NOT in keyChecksums + metadataFile.metadata.metadataKey = EncryptionUtils.generateKey() + + val signature = encryptionUtilsV2.getMessageSignature(enc1Cert, enc1PrivateKey, encrypted) + + val result = encryptionUtilsV2.verifyMetadata(encrypted, metadataFile, 0, signature) + + assertFalse(result) + } @Throws(Throwable::class) @Test diff --git a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt index 8751961df3d0..49b0763400da 100644 --- a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt +++ b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt @@ -102,7 +102,8 @@ import com.owncloud.android.db.ProviderMeta // manual migration used for 99 to 100 AutoMigration(from = 100, to = 101, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), AutoMigration(from = 101, to = 102, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), - AutoMigration(from = 102, to = 103, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class) + AutoMigration(from = 102, to = 103, spec = DatabaseMigrationUtil.ResetCapabilitiesPostMigration::class), + AutoMigration(from = 103, to = 104) ], exportSchema = true ) diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt index 164df5698362..ed5aab7755af 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -188,4 +188,7 @@ interface FileDao { @Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath") fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int + + @Query("UPDATE filelist SET is_read_only = :readOnly WHERE file_owner = :fileOwner AND path = :path") + fun setReadOnly(fileOwner: String, path: String, readOnly: Int): Int } diff --git a/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt index 175287ba738c..89ff2b616cb9 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt @@ -121,5 +121,7 @@ data class FileEntity( @ColumnInfo(name = ProviderTableMeta.FILE_INTERNAL_TWO_WAY_SYNC_RESULT) val internalTwoWaySyncResult: String?, @ColumnInfo(name = ProviderTableMeta.FILE_UPLOADED) - val uploaded: Long? + val uploaded: Long?, + @ColumnInfo(name = ProviderTableMeta.FILE_IS_READ_ONLY) + val isReadOnly: Int? ) diff --git a/app/src/main/java/com/nextcloud/client/di/AppModule.java b/app/src/main/java/com/nextcloud/client/di/AppModule.java index 481ef6719d28..6df3a013d9e4 100644 --- a/app/src/main/java/com/nextcloud/client/di/AppModule.java +++ b/app/src/main/java/com/nextcloud/client/di/AppModule.java @@ -38,17 +38,21 @@ import com.nextcloud.client.migrations.MigrationsManager; import com.nextcloud.client.migrations.MigrationsManagerImpl; import com.nextcloud.client.network.ClientFactory; +import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.client.notifications.AppNotificationManager; import com.nextcloud.client.notifications.AppNotificationManagerImpl; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.client.utils.Throttler; -import com.owncloud.android.providers.UsersAndGroupsSearchConfig; +import com.nextcloud.utils.e2ee.E2EEActionResolver; +import com.nextcloud.utils.e2ee.E2EEKeyInspector; +import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator; import com.owncloud.android.authentication.PassCodeManager; import com.owncloud.android.datamodel.ArbitraryDataProvider; import com.owncloud.android.datamodel.ArbitraryDataProviderImpl; import com.owncloud.android.datamodel.FileDataStorageManager; import com.owncloud.android.datamodel.SyncedFolderProvider; import com.owncloud.android.datamodel.UploadsStorageManager; +import com.owncloud.android.providers.UsersAndGroupsSearchConfig; import com.owncloud.android.ui.activities.data.activities.ActivitiesRepository; import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApi; import com.owncloud.android.ui.activities.data.activities.ActivitiesServiceApiImpl; @@ -57,7 +61,6 @@ import com.owncloud.android.ui.activities.data.files.FilesServiceApiImpl; import com.owncloud.android.ui.activities.data.files.RemoteFilesRepository; import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator; -import com.nextcloud.utils.thumbnail.FolderThumbnailGenerator; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.EventBus; @@ -280,4 +283,23 @@ FolderThumbnailGenerator folderThumbnailGenerator( return new FolderThumbnailGenerator(appPreferences, viewThemeUtils, context, accountManager); } + @Provides + E2EEKeyInspector e2eeKeyInspector( + Context context, + FileDataStorageManager fileDataStorageManager, + CertificateValidator certificateValidator, + ArbitraryDataProvider arbitraryDataProvider, + UserAccountManager accountManager) { + return new E2EEKeyInspector(context, fileDataStorageManager, certificateValidator, arbitraryDataProvider, accountManager); + } + + @Provides + E2EEActionResolver e2eeActionResolver( + FileDataStorageManager fileDataStorageManager, + ArbitraryDataProvider arbitraryDataProvider, + UserAccountManager accountManager, + ConnectivityService connectivityService, + E2EEKeyInspector e2eeKeyInspector) { + return new E2EEActionResolver(fileDataStorageManager, arbitraryDataProvider, accountManager, connectivityService, e2eeKeyInspector); + } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt index b53a4cc5dc04..8a1b966031ad 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/offlineOperations/OfflineOperationsWorker.kt @@ -18,6 +18,7 @@ import com.nextcloud.client.network.ConnectivityService import com.nextcloud.model.OfflineOperationType import com.nextcloud.model.WorkerState import com.nextcloud.model.WorkerStateObserver +import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.OwnCloudClient @@ -35,8 +36,6 @@ import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine private typealias OfflineOperationResult = Pair?, RemoteOperation<*>?>? @@ -67,7 +66,7 @@ class OfflineOperationsWorker( Log_OC.d(TAG, "[$jobName] OfflineOperationsWorker started for user: ${user.accountName}") // check network connection - if (!isNetworkAndServerAvailable()) { + if (!connectivityService.isNetworkAndServerAvailableSuspended()) { Log_OC.w(TAG, "⚠️ No internet/server connection. Retrying later...") return@withContext Result.retry() } @@ -161,12 +160,6 @@ class OfflineOperationsWorker( } // endregion - private suspend fun isNetworkAndServerAvailable(): Boolean = suspendCoroutine { continuation -> - connectivityService.isNetworkAndServerAvailable { result -> - continuation.resume(result) - } - } - // region Operation Execution @Suppress("ComplexCondition", "LongMethod") private suspend fun executeOperation( diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt new file mode 100644 index 000000000000..db221aaeb968 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEActionResolver.kt @@ -0,0 +1,90 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.nextcloud.utils.extensions.isNetworkAndServerAvailableSuspended +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import com.owncloud.android.utils.EncryptionUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@Suppress("LongParameterList") +class E2EEActionResolver @Inject constructor( + private val storageManager: FileDataStorageManager, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val accountManager: UserAccountManager, + private val connectivityService: ConnectivityService, + private val inspector: E2EEKeyInspector +) : Injectable { + + companion object { + private const val TAG = "E2EEActionResolver" + } + + suspend fun markFolderReadOnly(file: OCFile) = withContext(Dispatchers.IO) { + storageManager.setReadOnly(file, true) + } + + suspend fun checkFolderMetadataKey(file: OCFile): Boolean = withContext(Dispatchers.IO) { + val capability = storageManager.getCapability(accountManager.user) + val canDecrypt = inspector.canDecryptFolderMetadata(file, capability) + storageManager.setReadOnly(file, !canDecrypt) + return@withContext canDecrypt + } + + suspend fun checkKeys(): E2EEKeyCheck = withContext(Dispatchers.IO) { resolveKeyCheck() } + + private suspend fun resolveKeyCheck(): E2EEKeyCheck { + val keysAbsentLocally = inspector.isLocalKeysAbsent() + + if (!inspector.fetchCapabilities()) { + return if (connectivityService.isNetworkAndServerAvailableSuspended()) { + E2EEKeyCheck.CHECK_FAILED + } else { + E2EEKeyCheck.NO_NETWORK + } + } + + val capability = storageManager.getCapability(accountManager.user) + val keysExistOnServer = capability.endToEndEncryptionKeysExist + + val result = when { + keysExistOnServer.isUnknown || capability.endToEndEncryption.isUnknown -> E2EEKeyCheck.E2EE_UNAVAILABLE + keysAbsentLocally && keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_SERVER + keysAbsentLocally -> E2EEKeyCheck.MISSING_EVERYWHERE + !keysExistOnServer.isTrue -> E2EEKeyCheck.ONLY_ON_DEVICE + else -> compareKeys() + } + + Log_OC.d(TAG, "e2ee key check result: $result") + + return result + } + + private suspend fun compareKeys(): E2EEKeyCheck { + val storedPublicKey = arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY) + + return when (val result = inspector.compareWithServerKey(storedPublicKey)) { + is DownloadKeyResult.CompareKeys -> + if (result.same) E2EEKeyCheck.SAME_AS_SERVER else E2EEKeyCheck.DIFFERS_FROM_SERVER + + is DownloadKeyResult.NoServerKey -> E2EEKeyCheck.ONLY_ON_DEVICE + + else -> E2EEKeyCheck.CHECK_FAILED + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt new file mode 100644 index 000000000000..6acb8e841d51 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEDialogPresenter.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import com.nextcloud.utils.e2ee.model.E2EEDialog +import com.owncloud.android.R +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment +import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener +import com.owncloud.android.ui.fragment.OCFileListFragment + +class E2EEDialogPresenter(private val fragment: OCFileListFragment) { + + companion object { + private const val TAG = "E2EEDialogPresenter" + private const val ENCRYPTION_KEY_ALERT_DIALOG_TAG = "ENCRYPTION_KEY_HANDLER_DIALOG" + private const val NO_BUTTON = -1 + } + + fun show(dialog: E2EEDialog) { + if (fragment.parentFragmentManager.findFragmentByTag(ENCRYPTION_KEY_ALERT_DIALOG_TAG) != null) { + return + } + + ConfirmationDialogFragment + .newInstance( + titleResId = dialog.titleId, + titleIconId = R.drawable.ic_lock_open_white, + messageResId = dialog.descriptionId, + positiveButtonTextId = R.string.common_ok, + neutralButtonTextId = NO_BUTTON, + negativeButtonTextId = NO_BUTTON, + messageArguments = null + ).apply { + setOnConfirmationListener(dismissListener(dialog)) + }.show(fragment.parentFragmentManager, ENCRYPTION_KEY_ALERT_DIALOG_TAG) + } + + private fun dismissListener(dialog: E2EEDialog): ConfirmationDialogFragmentListener = + object : ConfirmationDialogFragmentListener { + override fun onConfirmation(callerTag: String?) = Log_OC.d(TAG, "$dialog acknowledged") + override fun onNeutral(callerTag: String?) = Unit + override fun onCancel(callerTag: String?) = Unit + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt new file mode 100644 index 000000000000..224fc8364b11 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2EEKeyInspector.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee + +import android.content.Context +import com.google.gson.reflect.TypeToken +import com.nextcloud.client.account.UserAccountManager +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.encrypted.EncryptedFolderMetadataFile +import com.owncloud.android.lib.common.OwnCloudClientFactory +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.e2ee.GetMetadataRemoteOperation +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.lib.resources.users.GetPublicKeyRemoteOperation +import com.owncloud.android.lib.resources.users.GetServerPublicKeyRemoteOperation +import com.owncloud.android.operations.GetCapabilitiesOperation +import com.owncloud.android.ui.dialog.setupEncryption.CertificateValidator +import com.owncloud.android.ui.dialog.setupEncryption.model.DownloadKeyResult +import com.owncloud.android.utils.EncryptionUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.httpclient.HttpStatus +import javax.inject.Inject + +@Suppress("TooGenericExceptionCaught", "ReturnCount") +class E2EEKeyInspector @Inject constructor( + private val context: Context, + private val storageManager: FileDataStorageManager, + private val certificateValidator: CertificateValidator, + private val arbitraryDataProvider: ArbitraryDataProvider, + private val accountManager: UserAccountManager +) { + companion object { + private const val TAG = "E2EEKeyInspector" + } + + fun isLocalKeysAbsent(): Boolean = + arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PRIVATE_KEY).isEmpty() || + arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PUBLIC_KEY).isEmpty() + + suspend fun fetchCapabilities(): Boolean = withContext(Dispatchers.IO) { + val result = GetCapabilitiesOperation(storageManager).execute(context).isSuccess + if (result) { + Log_OC.i(TAG, "capabilities fetched") + } else { + Log_OC.e(TAG, "error fetching capabilities") + } + return@withContext result + } + + suspend fun compareWithServerKey(storedPublicKey: String): DownloadKeyResult = withContext(Dispatchers.IO) { + Log_OC.d(TAG, "comparing local public key with remote public key") + + val user = accountManager.user + + val certificateResult = GetPublicKeyRemoteOperation().executeNextcloudClient(user, context) + if (!certificateResult.isSuccess) { + return@withContext if (certificateResult.httpCode == HttpStatus.SC_NOT_FOUND) { + DownloadKeyResult.NoServerKey + } else { + DownloadKeyResult.CertificateUnavailable() + } + } + + val serverPublicKeyResult = GetServerPublicKeyRemoteOperation().executeNextcloudClient(user, context) + if (!serverPublicKeyResult.isSuccess) return@withContext DownloadKeyResult.ServerPublicKeyUnavailable() + + val isCertificateValid = certificateValidator.validate( + serverPublicKeyResult.resultData, + certificateResult.resultData + ) + if (!isCertificateValid) return@withContext DownloadKeyResult.CertificateVerificationFailed() + + return@withContext DownloadKeyResult.CompareKeys(same = (storedPublicKey == certificateResult.resultData)) + } + + suspend fun canDecryptFolderMetadata(folder: OCFile, capability: OCCapability): Boolean = + withContext(Dispatchers.IO) { + Log_OC.d(TAG, "checking folder metadata key") + + val client = OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, context) + val metadataResult = GetMetadataRemoteOperation(folder.localId).execute(client) + + if (!metadataResult.isSuccess) { + return@withContext false + } + + val privateKey = arbitraryDataProvider.getValue(accountManager.user, EncryptionUtils.PRIVATE_KEY) + if (privateKey.isEmpty()) { + Log_OC.e(TAG, "user try to decrypt folder with empty private key") + return@withContext false + } + + val metadata = metadataResult.resultData + + return@withContext if (E2EVersionHelper.isV2Plus(capability)) { + decryptsMetadataV2(metadata.metadata, privateKey, client.userId) + } else { + decryptsMetadataV1(metadata.metadata, privateKey, folder.localId) + } + } + + private fun decryptsMetadataV2(serializedMetadata: String, privateKey: String, userId: String): Boolean { + val metadataFile = EncryptionUtils.deserializeJSON( + serializedMetadata, + object : TypeToken() {} + ) + + val user = metadataFile.users.find { it.userId == userId } + ?: throw IllegalStateException("cannot find current user in metadata") + + return try { + EncryptionUtils.decryptStringAsymmetricV2(user.encryptedMetadataKey, privateKey) + true + } catch (e: Exception) { + Log_OC.w(TAG, "user tried to decrypt folder's metadata with different private key: $e") + false + } + } + + private fun decryptsMetadataV1(serializedMetadata: String, privateKey: String, folderLocalId: Long): Boolean { + val metadataFile = EncryptionUtils.deserializeJSON( + serializedMetadata, + object : TypeToken() {} + ) + + return try { + EncryptionUtils.decryptFolderMetaData( + metadataFile, + privateKey, + arbitraryDataProvider, + accountManager.user, + folderLocalId + ) + true + } catch (e: Exception) { + Log_OC.w(TAG, "user tried to decrypt folder's metadata with different private key: $e") + false + } + } +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEAction.kt b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEAction.kt new file mode 100644 index 000000000000..d746c42db010 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEAction.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee.model + +enum class E2EEAction { + NEW_FOLDER, + OPEN, + ENCRYPT +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEDialog.kt b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEDialog.kt new file mode 100644 index 000000000000..489f30d94407 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEDialog.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee.model + +import androidx.annotation.StringRes +import com.owncloud.android.R + +enum class E2EEDialog(@param:StringRes val titleId: Int, @param:StringRes val descriptionId: Int) { + KeysRevokedOrChangedWhenEncrypting( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_setup_new_key_before_encrypting_description + ), + + KeysLocallyOutdated( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_key_keys_only_on_device_outdated_description + ), + + KeysOnlyExistsOnDeviceWhenCreatingFolder( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_key_keys_only_on_device_description + ), + + KeysUnusableWhenOpeningFolder( + titleId = R.string.encryption_key_handle_key_removed_title, + descriptionId = R.string.encryption_key_handle_folder_read_only_description + ) +} diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheck.kt b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheck.kt new file mode 100644 index 000000000000..38764220c6ef --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/e2ee/model/E2EEKeyCheck.kt @@ -0,0 +1,111 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.e2ee.model + +import com.owncloud.android.R + +enum class E2EEKeyCheck { + NO_NETWORK, + CHECK_FAILED, + E2EE_UNAVAILABLE, + ONLY_ON_SERVER, + MISSING_EVERYWHERE, + ONLY_ON_DEVICE, + SAME_AS_SERVER, + DIFFERS_FROM_SERVER; + + fun getDialog(action: E2EEAction): E2EEDialog? = when (this) { + ONLY_ON_DEVICE -> { + when (action) { + E2EEAction.OPEN -> { + E2EEDialog.KeysUnusableWhenOpeningFolder + } + + E2EEAction.ENCRYPT -> { + E2EEDialog.KeysRevokedOrChangedWhenEncrypting + } + + else -> { + E2EEDialog.KeysOnlyExistsOnDeviceWhenCreatingFolder + } + } + } + + DIFFERS_FROM_SERVER -> { + when (action) { + E2EEAction.OPEN -> { + E2EEDialog.KeysUnusableWhenOpeningFolder + } + + E2EEAction.ENCRYPT -> { + E2EEDialog.KeysRevokedOrChangedWhenEncrypting + } + + else -> { + E2EEDialog.KeysLocallyOutdated + } + } + } + + else -> null + } + + fun getMessageId(action: E2EEAction): Int? = when (this) { + NO_NETWORK -> when (action) { + E2EEAction.NEW_FOLDER -> { + R.string.encryption_new_folder_no_network + } + + E2EEAction.OPEN -> { + R.string.encryption_open_no_network + } + + else -> { + R.string.encryption_encrypt_no_network + } + } + + CHECK_FAILED -> when (action) { + E2EEAction.NEW_FOLDER -> { + R.string.encryption_new_folder_check_failed + } + + E2EEAction.OPEN -> { + R.string.encryption_open_check_failed + } + + else -> { + R.string.encryption_encrypt_check_failed + } + } + + E2EE_UNAVAILABLE -> when (action) { + E2EEAction.NEW_FOLDER -> { + R.string.encryption_new_folder_unavailable + } + + E2EEAction.OPEN -> { + R.string.encryption_open_unavailable + } + + else -> { + R.string.encryption_encrypt_unavailable + } + } + + MISSING_EVERYWHERE -> { + if (action == E2EEAction.OPEN) { + R.string.encryption_key_handle_no_keys_description + } else { + null + } + } + + else -> null + } +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/ConnectivityServiceExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/ConnectivityServiceExtensions.kt new file mode 100644 index 000000000000..7038e6672a3d --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/ConnectivityServiceExtensions.kt @@ -0,0 +1,21 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.client.network.ConnectivityService +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +suspend fun ConnectivityService.isNetworkAndServerAvailableSuspended(): Boolean = + suspendCancellableCoroutine { continuation -> + isNetworkAndServerAvailable { available -> + if (continuation.isActive) { + continuation.resume(available) + } + } + } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt index b8db4fa83bc4..ae8b84b1efdc 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileExtensions.kt @@ -15,6 +15,7 @@ import com.owncloud.android.datamodel.OCFileDepth import com.owncloud.android.datamodel.OCFileDepth.DeepLevel import com.owncloud.android.datamodel.OCFileDepth.FirstLevel import com.owncloud.android.datamodel.OCFileDepth.Root +import com.owncloud.android.ui.events.EncryptionEvent import com.owncloud.android.utils.FileStorageUtils import java.util.Calendar import java.util.Date @@ -82,3 +83,10 @@ private fun transformToRows(list: List, columns: Int, defaultSize: Int): .chunked(columns) .map { chunk -> GalleryRow(chunk, defaultSize, defaultSize) } } + +fun OCFile.toEncryptionEvent(encrypt: Boolean): EncryptionEvent = EncryptionEvent( + localId, + remoteId, + remotePath, + encrypt +) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OCFileListFragmentExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OCFileListFragmentExtensions.kt new file mode 100644 index 000000000000..89519699de9e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/OCFileListFragmentExtensions.kt @@ -0,0 +1,84 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import android.os.Bundle +import androidx.lifecycle.lifecycleScope +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.Companion.ARG_FILE_PATH +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.Companion.RESULT_REQUEST_KEY +import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.Companion.SUCCESS +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.launch + +private const val TAG = "OCFileListFragmentExtensions" + +fun OCFileListFragment.showEncryptionDialog(remotePath: String?) { + if (parentFragmentManager.findFragmentByTag(SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG) != null) { + return + } + + val user = accountManager.user + val connectivityService = typedActivity()?.connectivityService + connectivityService?.isNetworkAndServerAvailable { result -> + if (result == true) { + SetupEncryptionDialogFragment.newInstance(user, remotePath) + .show(parentFragmentManager, SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG) + return@isNetworkAndServerAvailable + } + + DisplayUtils.showSnackMessage( + this, + R.string.internet_connection_required_for_encrypted_folder_setup + ) + } +} + +fun OCFileListFragment.listenEncryptionDialogResult() { + parentFragmentManager.setFragmentResultListener( + RESULT_REQUEST_KEY, + this + ) { _: String?, bundle: Bundle -> + val result = bundle.getBoolean(SUCCESS, false) + if (!result) { + Log_OC.d(TAG, "setup encryption dialog is dismissed") + return@setFragmentResultListener + } + + val fileRemotePath = bundle.getString(ARG_FILE_PATH, null) + if (fileRemotePath == null) { + Log_OC.e(TAG, "file path is null") + return@setFragmentResultListener + } + + val file: OCFile? = mContainerActivity.getStorageManager().getFileByDecryptedRemotePath(fileRemotePath) + if (file == null) { + Log_OC.e(TAG, "file is null, cannot toggle encryption") + return@setFragmentResultListener + } + + if (file.isRootDirectory) { + Log_OC.d( + TAG, + "result of setup encryption triggered in root directory, this call is for " + + "creating encrypted folder" + ) + createFolder(true) + return@setFragmentResultListener + } + + lifecycleScope.launch { + folderEncryption.toggle(file.toEncryptionEvent(true)) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 15422af9d97e..3bbd25b3ffa9 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -839,6 +839,7 @@ private ContentValues createContentValuesBase(OCFile fileOrFolder) { cv.put(ProviderTableMeta.FILE_PATH_DECRYPTED, fileOrFolder.getDecryptedRemotePath()); cv.put(ProviderTableMeta.FILE_ACCOUNT_OWNER, user.getAccountName()); cv.put(ProviderTableMeta.FILE_IS_ENCRYPTED, fileOrFolder.isEncrypted()); + cv.put(ProviderTableMeta.FILE_IS_READ_ONLY, fileOrFolder.isReadOnly()); cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE, fileOrFolder.getLastSyncDateForProperties()); cv.put(ProviderTableMeta.FILE_LAST_SYNC_DATE_FOR_DATA, fileOrFolder.getLastSyncDateForData()); cv.put(ProviderTableMeta.FILE_ETAG, fileOrFolder.getEtag()); @@ -1396,6 +1397,7 @@ public OCFile createFileInstance(FileEntity fileEntity) { ocFile.setEtagInConflict(fileEntity.getEtagInConflict()); ocFile.setFavorite(nullToZero(fileEntity.getFavorite()) == 1); ocFile.setEncrypted(nullToZero(fileEntity.isEncrypted()) == 1); + ocFile.setReadOnly(nullToZero(fileEntity.isReadOnly()) == 1); // if (ocFile.isEncrypted()) { // ocFile.setFileName(cursor.getString(cursor.getColumnIndexOrThrow(ProviderTableMeta.FILE_NAME))); // } @@ -2837,6 +2839,34 @@ public void removeLocalFiles(User user, FileDataStorageManager storageManager) { FileStorageUtils.deleteRecursively(saveDir, storageManager); } + public void setReadOnly(OCFile file, boolean readOnly) { + OCFile root = getEncryptedRootFolder(file); + if (root != null) { + fileDao.setReadOnly(user.getAccountName(), root.getRemotePath(), readOnly ? 1 : 0); + } + } + + public boolean isReadOnly(OCFile file) { + OCFile root = getEncryptedRootFolder(file); + return root != null && root.isReadOnly(); + } + + @Nullable + private OCFile getEncryptedRootFolder(OCFile file) { + if (file == null || !file.isEncrypted()) { + return null; + } + + OCFile root = null; + OCFile current = getFileById(file.getFileId()); + while (current != null && current.isEncrypted()) { + root = current; + current = getFileById(current.getParentId()); + } + + return root; + } + public List getAllFiles() { // TODO - Apparently this method is used only by tests List fileEntities = fileDao.getAllFiles(user.getAccountName()); diff --git a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java index eac2dc488c9f..5626e13771fa 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/OCFile.java +++ b/app/src/main/java/com/owncloud/android/datamodel/OCFile.java @@ -100,6 +100,7 @@ public class OCFile implements Parcelable, Comparable, ServerFileInterfa private boolean favorite; private boolean hidden; private boolean encrypted; + private boolean readOnly; private WebdavEntry.MountType mountType; private int unreadCommentsCount; private String ownerId; @@ -197,6 +198,7 @@ private OCFile(Parcel source) { favorite = source.readInt() == 1; hidden = source.readInt() == 1; encrypted = source.readInt() == 1; + readOnly = source.readInt() == 1; ownerId = source.readString(); ownerDisplayName = source.readString(); mountType = (WebdavEntry.MountType) source.readSerializable(); @@ -243,6 +245,7 @@ public void writeToParcel(Parcel dest, int flags) { dest.writeInt(favorite ? 1 : 0); dest.writeInt(hidden ? 1 : 0); dest.writeInt(encrypted ? 1 : 0); + dest.writeInt(readOnly ? 1 : 0); dest.writeString(ownerId); dest.writeString(ownerDisplayName); dest.writeSerializable(mountType); @@ -517,6 +520,7 @@ private void resetData() { favorite = false; hidden = false; encrypted = false; + readOnly = false; mountType = WebdavEntry.MountType.INTERNAL; richWorkspace = ""; firstShareTimestamp = 0; @@ -851,6 +855,10 @@ public boolean isEncrypted() { return this.encrypted; } + public boolean isReadOnly() { + return this.readOnly; + } + public WebdavEntry.MountType getMountType() { return this.mountType; } @@ -967,6 +975,10 @@ public void setEncrypted(boolean encrypted) { this.encrypted = encrypted; } + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + } + public void setMountType(WebdavEntry.MountType mountType) { this.mountType = mountType; } diff --git a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java index d966f43e4e23..173b81fb9a19 100644 --- a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java +++ b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -24,7 +24,7 @@ */ public class ProviderMeta { public static final String DB_NAME = "filelist"; - public static final int DB_VERSION = 103; + public static final int DB_VERSION = 104; private ProviderMeta() { // No instance @@ -115,6 +115,7 @@ static public class ProviderTableMeta implements BaseColumns { public static final String FILE_FAVORITE = "favorite"; public static final String FILE_HIDDEN = "hidden"; public static final String FILE_IS_ENCRYPTED = "is_encrypted"; + public static final String FILE_IS_READ_ONLY = "is_read_only"; public static final String FILE_MOUNT_TYPE = "mount_type"; public static final String FILE_HAS_PREVIEW = "has_preview"; public static final String FILE_UNREAD_COMMENTS_COUNT = "unread_comments_count"; @@ -169,6 +170,7 @@ static public class ProviderTableMeta implements BaseColumns { FILE_FAVORITE, FILE_HIDDEN, FILE_IS_ENCRYPTED, + FILE_IS_READ_ONLY, FILE_MOUNT_TYPE, FILE_HAS_PREVIEW, FILE_UNREAD_COMMENTS_COUNT, diff --git a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java index 47f103ead48f..0d7fa33d94a6 100644 --- a/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java +++ b/app/src/main/java/com/owncloud/android/files/FileMenuFilter.java @@ -177,6 +177,7 @@ private List filter(boolean inSingleFileFragment) { filterPinToHome(toHide); filterRetry(toHide); filterPermissionActions(toHide); + filterReadOnly(toHide); return toHide; } @@ -385,6 +386,31 @@ private void filterSelectAll(List toHide, boolean inSingleFileFragment) } } + private void filterReadOnly(Collection toHide) { + boolean anyReadOnly = false; + for (OCFile file : files) { + if (storageManager.isReadOnly(file)) { + anyReadOnly = true; + break; + } + } + + if (!anyReadOnly) { + return; + } + + toHide.add(R.id.action_remove_file); + toHide.add(R.id.action_rename_file); + toHide.add(R.id.action_move_or_copy); + toHide.add(R.id.action_edit); + toHide.add(R.id.action_encrypted); + toHide.add(R.id.action_unset_encrypted); + toHide.add(R.id.action_lock_file); + toHide.add(R.id.action_unlock_file); + toHide.add(R.id.action_favorite); + toHide.add(R.id.action_unset_favorite); + } + private void filterRemove(List toHide, boolean synchronizing) { if (files.isEmpty() || synchronizing || containsLockedFile() || containsEncryptedFolder() || isFolderAndContainsEncryptedFile()) { diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java index 9dc3cbf43f36..85af19137bc7 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -615,6 +615,7 @@ private void synchronizeData(List folderAndFiles) { // we parse content, so either the folder itself or its direct parent (which we check) must be encrypted boolean encrypted = updatedFile.isEncrypted() || mLocalFolder.isEncrypted(); updatedFile.setEncrypted(encrypted); + updatedFile.setReadOnly(localFile != null && localFile.isReadOnly()); updatedFiles.add(updatedFile); } diff --git a/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt index 65f438fa5c56..9ad74e9edf49 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/SetupEncryptionActivity.kt @@ -16,6 +16,9 @@ import com.owncloud.android.R import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment import com.owncloud.android.utils.DisplayUtils +/** + * Only needed for SettingsActivity + */ class SetupEncryptionActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -45,16 +48,14 @@ class SetupEncryptionActivity : AppCompatActivity() { setupEncryptionDialogFragment.show(supportFragmentManager, "setup_encryption") } - private fun buildResultIntentFromBundle(result: Bundle): Intent { - val intent = Intent() - intent.putExtra( + private fun buildResultIntentFromBundle(result: Bundle): Intent = Intent().apply { + putExtra( SetupEncryptionDialogFragment.SUCCESS, result.getBoolean(SetupEncryptionDialogFragment.SUCCESS) ) - intent.putExtra( + putExtra( SetupEncryptionDialogFragment.ARG_FILE_PATH, result.getInt(SetupEncryptionDialogFragment.ARG_FILE_PATH) ) - return intent } } diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt index 2d7c59341e3e..a57fd121608d 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/setupEncryption/model/DownloadKeyResult.kt @@ -32,5 +32,9 @@ sealed class DownloadKeyResult(open val descriptionId: Int? = null) { data object GeneratePassphraseSendCSR : DownloadKeyResult() + data object NoServerKey : DownloadKeyResult() + + data class CompareKeys(val same: Boolean) : DownloadKeyResult() + data class Success(val privateKey: String) : DownloadKeyResult() } diff --git a/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt b/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt index 57bd97bf5caa..1a7dbc56c9a4 100644 --- a/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt +++ b/app/src/main/java/com/owncloud/android/ui/events/EncryptionEvent.kt @@ -6,7 +6,37 @@ */ package com.owncloud.android.ui.events +import androidx.lifecycle.lifecycleScope +import com.nextcloud.utils.e2ee.model.E2EEAction +import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.nextcloud.utils.extensions.showEncryptionDialog +import com.owncloud.android.ui.fragment.OCFileListFragment +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.launch + /** * Event for set folder as encrypted/decrypted */ -class EncryptionEvent(val localId: Long, val remoteId: String, val remotePath: String, val shouldBeEncrypted: Boolean) +class EncryptionEvent(val localId: Long, val remoteId: String, val remotePath: String, val shouldBeEncrypted: Boolean) { + fun onResult(fragment: OCFileListFragment) { + fragment.lifecycleScope.launch { + when (val state = fragment.e2eeActionResolver.checkKeys()) { + E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.E2EE_UNAVAILABLE, E2EEKeyCheck.CHECK_FAILED -> { + state.getMessageId(E2EEAction.ENCRYPT)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_SERVER, E2EEKeyCheck.MISSING_EVERYWHERE -> { + fragment.showEncryptionDialog(remotePath) + } + + E2EEKeyCheck.ONLY_ON_DEVICE, E2EEKeyCheck.DIFFERS_FROM_SERVER -> { + state.getDialog(E2EEAction.ENCRYPT)?.let { fragment.e2eeDialogPresenter.show(it) } + } + + E2EEKeyCheck.SAME_AS_SERVER -> { + fragment.folderEncryption.toggle(this@EncryptionEvent) + } + } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt new file mode 100644 index 000000000000..e182094edc46 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/EncryptedFolderClickHandler.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment + +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.utils.e2ee.model.E2EEAction +import com.nextcloud.utils.e2ee.model.E2EEKeyCheck +import com.nextcloud.utils.extensions.showEncryptionDialog +import com.owncloud.android.R +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.ui.activity.FileActivity +import com.owncloud.android.ui.activity.FolderPickerActivity +import com.owncloud.android.ui.helpers.FileOperationsHelper +import com.owncloud.android.utils.DisplayUtils +import kotlinx.coroutines.launch + +class EncryptedFolderClickHandler(private val fragment: OCFileListFragment) { + + companion object { + private const val TAG = "EncryptedFolderClickHandler" + } + + private var checkingKeysSnackbar: Snackbar? = null + + /** + * Executed when user taps the 'New encrypted folder' action. + */ + fun onNewEncryptedFolder() { + checkingKeysSnackbar = DisplayUtils.createAndShowSnackMessage( + fragment, + R.string.encryption_key_checking_keys + ) + + fragment.lifecycleScope.launch { + val state = fragment.e2eeActionResolver.checkKeys() + dismissCheckingSnackbar() + when (state) { + E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.CHECK_FAILED, E2EEKeyCheck.E2EE_UNAVAILABLE -> { + state.getMessageId(E2EEAction.NEW_FOLDER)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_SERVER, E2EEKeyCheck.MISSING_EVERYWHERE -> { + Log_OC.d(TAG, "keys found on server but missing locally, redirecting to encryption setup") + fragment.showEncryptionDialog(OCFile.ROOT_PATH) + } + + E2EEKeyCheck.SAME_AS_SERVER -> { + Log_OC.d(TAG, "keys valid, proceeding with new encrypted folder creation") + fragment.showCreateFolderDialog(true) + } + + E2EEKeyCheck.ONLY_ON_DEVICE, E2EEKeyCheck.DIFFERS_FROM_SERVER -> { + state.getDialog(E2EEAction.NEW_FOLDER)?.let { fragment.e2eeDialogPresenter.show(it) } + } + } + } + } + + fun onEncryptedFolderClick(file: OCFile, position: Int) { + val fileActivity = fragment.activity as? FileActivity ?: run { + Log_OC.e(TAG, "activity is not file activity, cannot handle encrypted folder") + return + } + + checkingKeysSnackbar = DisplayUtils.createAndShowSnackMessage( + fragment, + R.string.encryption_key_checking_keys + ) + + fragment.run { + lifecycleScope.launch { + val state = e2eeActionResolver.checkKeys() + + dismissCheckingSnackbar() + + when (state) { + E2EEKeyCheck.NO_NETWORK, E2EEKeyCheck.CHECK_FAILED, E2EEKeyCheck.E2EE_UNAVAILABLE -> { + state.getMessageId(E2EEAction.OPEN)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_SERVER -> { + fragment.showEncryptionDialog(file.remotePath) + } + + E2EEKeyCheck.MISSING_EVERYWHERE -> { + e2eeActionResolver.markFolderReadOnly(file) + state.getMessageId(E2EEAction.OPEN)?.let { DisplayUtils.showSnackMessage(fragment, it) } + } + + E2EEKeyCheck.ONLY_ON_DEVICE, E2EEKeyCheck.DIFFERS_FROM_SERVER -> { + e2eeActionResolver.markFolderReadOnly(file) + state.getDialog(E2EEAction.OPEN)?.let { e2eeDialogPresenter.show(it) } + } + + E2EEKeyCheck.SAME_AS_SERVER -> { + if (e2eeActionResolver.checkFolderMetadataKey(file)) { + onFolderKeyVerified(file, position, fileActivity) + } else { + DisplayUtils.showSnackMessage( + fragment, + R.string.encryption_open_key_mismatch + ) + } + } + } + } + } + } + + private fun dismissCheckingSnackbar() { + DisplayUtils.dismissSnackMessage(checkingKeysSnackbar) + checkingKeysSnackbar = null + } + + private fun onFolderKeyVerified(file: OCFile, position: Int, fileActivity: FileActivity) { + val user = fileActivity.user.orElseThrow { RuntimeException() } + val capability = fragment.mContainerActivity.getStorageManager().getCapability(user.accountName) + + if (capability.endToEndEncryption.isFalse || capability.endToEndEncryption.isUnknown) { + DisplayUtils.showSnackMessage(fragment, R.string.end_to_end_encryption_not_enabled) + return + } + + if (FileOperationsHelper.isEndToEndEncryptionSetup(fragment.context, user)) { + onEncryptionSetupComplete(file, position) + } else { + fragment.showEncryptionDialog(file.remotePath) + } + } + + private fun onEncryptionSetupComplete(file: OCFile?, position: Int) { + fragment.searchFragment = false + fragment.mHideFab = false + + val folderPickerActivity = fragment.mContainerActivity as? FolderPickerActivity + if (folderPickerActivity?.isDoNotEnterEncryptedFolder == true) { + DisplayUtils.showSnackMessage(fragment, R.string.copy_move_to_encrypted_folder_not_supported) + } else { + fragment.browseToFolder(file, position) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt new file mode 100644 index 000000000000..9823ec26effd --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FolderEncryption.kt @@ -0,0 +1,162 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment + +import com.nextcloud.client.account.User +import com.nextcloud.utils.e2ee.E2EVersionHelper +import com.owncloud.android.R +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation +import com.owncloud.android.lib.resources.status.E2EVersion +import com.owncloud.android.ui.events.EncryptionEvent +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.EncryptionUtilsV2 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.apache.commons.httpclient.HttpStatus + +@Suppress("TooGenericExceptionCaught") +class FolderEncryption(private val fragment: OCFileListFragment) { + + companion object { + private const val TAG = "FolderEncryption" + } + + suspend fun toggle(event: EncryptionEvent): Boolean = withContext(Dispatchers.IO) { + val localId = event.localId + val remoteId = event.remoteId + val remotePath = event.remotePath + val shouldBeEncrypted = event.shouldBeEncrypted + + try { + val storageManager = fragment.mContainerActivity.storageManager + val folder = storageManager.getFileByRemoteId(remoteId) ?: run { + Log_OC.e(TAG, "folder is null, cannot encrypt") + return@withContext false + } + + val user = fragment.accountManager.user + val provider = fragment.arbitraryDataProvider + val publicKey = provider.getValue(user, EncryptionUtils.PUBLIC_KEY) + val privateKey = provider.getValue(user, EncryptionUtils.PRIVATE_KEY) + val client = fragment.clientFactory.create(user) + + val result = ToggleEncryptionRemoteOperation(localId, remotePath, shouldBeEncrypted) + .execute(client) + + return@withContext when { + result.isSuccess -> onToggleSuccess( + remoteId, + shouldBeEncrypted, + folder, + client, + user, + publicKey, + privateKey, + storageManager + ) + + result.httpCode == HttpStatus.SC_FORBIDDEN -> { + showSnackbar( + R.string.end_to_end_encryption_folder_not_empty + ) + false + } + + else -> { + showSnackbar(R.string.common_error_unknown) + false + } + } + } catch (t: Throwable) { + Log_OC.e(TAG, "error encrypting folder", t) + false + } + } + + @Suppress("LongParameterList") + private suspend fun onToggleSuccess( + remoteId: String, + shouldBeEncrypted: Boolean, + folder: OCFile, + client: OwnCloudClient, + user: User, + publicKey: String, + privateKey: String, + storageManager: FileDataStorageManager + ): Boolean { + val capability = storageManager.getCapability(user.accountName) + val isE2EEV2 = E2EVersionHelper.isV2Plus(capability) + var e2eCounter = EncryptionUtils.E2E_V1_INITIAL_COUNTER + if (isE2EEV2) { + e2eCounter = EncryptionUtils.E2E_V2_INITIAL_COUNTER + } + val token = EncryptionUtils.lockFolder(folder, client, e2eCounter) + + val result = when { + isE2EEV2 -> { + val result = EncryptionUtils.retrieveMetadata( + folder, + client, + privateKey, + publicKey, + storageManager, + user, + fragment.requireContext(), + fragment.arbitraryDataProvider + ) + val encryptionUtil = EncryptionUtilsV2() + encryptionUtil.serializeAndUploadMetadata( + folder, + result.second, + token, + client, + result.first, + fragment.requireContext(), + user, + storageManager + ) + EncryptionUtils.unlockFolder(folder, client, token) + true + } + + E2EVersionHelper.isV1(capability) -> { + EncryptionUtils.unlockFolderV1(folder, client, token) + false + } + + capability.endToEndEncryptionApiVersion == E2EVersion.UNKNOWN -> { + throw IllegalArgumentException("Unknown E2E version") + } + + else -> { + false + } + } + + withContext(Dispatchers.Main) { + val isFileExists = (fragment.adapter.getFileByRemoteId(remoteId) != null) + if (!isFileExists) { + val newFile = storageManager.getFileByRemoteId(remoteId) + fragment.adapter.insertFile(newFile) + } + + fragment.adapter.updateFileEncryptionById(remoteId, shouldBeEncrypted) + } + + return result + } + + private suspend fun showSnackbar(messageResId: Int) = withContext(Dispatchers.Main) { + DisplayUtils.showSnackMessage(fragment, messageResId) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index 8a1c7335bdd7..f8d2549f7034 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -26,7 +26,6 @@ import android.os.Looper; import android.text.TextUtils; import android.util.Log; -import android.util.Pair; import android.view.ActionMode; import android.view.LayoutInflater; import android.view.Menu; @@ -56,12 +55,14 @@ import com.nextcloud.ui.fileactions.FileActionsBottomSheet; import com.nextcloud.utils.EditorUtils; import com.nextcloud.utils.ShortcutUtil; -import com.nextcloud.utils.e2ee.E2EVersionHelper; +import com.nextcloud.utils.e2ee.E2EEActionResolver; +import com.nextcloud.utils.e2ee.E2EEDialogPresenter; import com.nextcloud.utils.extensions.BundleExtensionsKt; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.FragmentExtensionsKt; import com.nextcloud.utils.extensions.IntentExtensionsKt; import com.nextcloud.utils.extensions.OCFileExtensionsKt; +import com.nextcloud.utils.extensions.OCFileListFragmentExtensionsKt; import com.nextcloud.utils.extensions.ViewExtensionsKt; import com.nextcloud.utils.fileNameValidator.FileNameValidator; import com.nextcloud.utils.thumbnail.ThumbnailGenerator; @@ -73,19 +74,13 @@ import com.owncloud.android.datamodel.OCFile; import com.owncloud.android.datamodel.OCFileDepth; import com.owncloud.android.datamodel.SyncedFolderProvider; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1; -import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedMetadata; -import com.owncloud.android.datamodel.e2e.v1.encrypted.EncryptedFolderMetadataFileV1; -import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile; import com.owncloud.android.lib.common.Creator; import com.owncloud.android.lib.common.OwnCloudClient; import com.owncloud.android.lib.common.operations.RemoteOperation; import com.owncloud.android.lib.common.operations.RemoteOperationResult; import com.owncloud.android.lib.common.utils.Log_OC; -import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation; import com.owncloud.android.lib.resources.files.SearchRemoteOperation; import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation; -import com.owncloud.android.lib.resources.status.E2EVersion; import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.lib.resources.status.Type; import com.owncloud.android.ui.activity.AlbumsPickerActivity; @@ -105,7 +100,6 @@ import com.owncloud.android.ui.dialog.RemoveFilesDialogFragment; import com.owncloud.android.ui.dialog.RenameFileDialogFragment; import com.owncloud.android.ui.dialog.SyncFileNotEnoughSpaceDialogFragment; -import com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment; import com.owncloud.android.ui.events.ChangeMenuEvent; import com.owncloud.android.ui.events.CommentsEvent; import com.owncloud.android.ui.events.EncryptionEvent; @@ -118,8 +112,6 @@ import com.owncloud.android.ui.preview.PreviewImageFragment; import com.owncloud.android.ui.preview.PreviewMediaActivity; import com.owncloud.android.utils.DisplayUtils; -import com.owncloud.android.utils.EncryptionUtils; -import com.owncloud.android.utils.EncryptionUtilsV2; import com.owncloud.android.utils.FileSortOrder; import com.owncloud.android.utils.FileStorageUtils; import com.owncloud.android.utils.MimeTypeUtil; @@ -127,14 +119,12 @@ import com.owncloud.android.utils.WebViewUtil; import com.owncloud.android.utils.theme.ThemeUtils; -import org.apache.commons.httpclient.HttpStatus; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import java.util.ArrayList; import java.util.Collection; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -149,13 +139,11 @@ import androidx.core.content.ContextCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.FragmentActivity; -import androidx.fragment.app.FragmentManager; import androidx.media3.common.util.UnstableApi; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import kotlin.Unit; import static com.owncloud.android.datamodel.OCFile.ROOT_PATH; -import static com.owncloud.android.ui.dialog.setupEncryption.SetupEncryptionDialogFragment.SETUP_ENCRYPTION_DIALOG_TAG; import static com.owncloud.android.ui.fragment.SearchType.FAVORITE_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.FILE_SEARCH; import static com.owncloud.android.ui.fragment.SearchType.GALLERY_SEARCH; @@ -214,8 +202,11 @@ public class OCFileListFragment extends ExtendedListFragment implements @Inject SyncedFolderProvider syncedFolderProvider; @Inject AppScanOptionalFeature appScanOptionalFeature; @Inject ThumbnailGenerator thumbnailGenerator; - - protected FileFragment.ContainerActivity mContainerActivity; + @Inject public E2EEActionResolver e2eeActionResolver; + public E2EEDialogPresenter e2eeDialogPresenter; + private EncryptedFolderClickHandler clickHandler; + public FolderEncryption folderEncryption; + public FileFragment.ContainerActivity mContainerActivity; protected OCFile mFile; protected OCFileListAdapter mAdapter; @@ -265,12 +256,15 @@ public void onCreate(Bundle savedInstanceState) { setSearchArgs(state); mFile = BundleExtensionsKt.getParcelableArgument(state, KEY_FILE, OCFile.class); searchFragment = currentSearchType != null && isSearchEventSet(searchEvent); + folderEncryption = new FolderEncryption(this); + clickHandler = new EncryptedFolderClickHandler(this); + e2eeDialogPresenter = new E2EEDialogPresenter(this); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); - listenSetupEncryptionDialogResult(); + OCFileListFragmentExtensionsKt.listenEncryptionDialogResult(this); } @Override @@ -428,7 +422,7 @@ public void prepareOCFileList(Bundle savedInstanceState) { setFabVisible(false); } else { if (mFile != null) { - setFabVisible(mFile.canCreateFileAndFolder()); + setFabVisible(canCreateInside(mFile)); } else { setFabVisible(true); } @@ -470,6 +464,19 @@ public void prepareOCFileList(Bundle savedInstanceState) { } } + private boolean isReadOnlyFolder(OCFile folder) { + if (folder == null || mContainerActivity == null) { + return false; + } + + final var storageManager = mContainerActivity.getStorageManager(); + return storageManager != null && storageManager.isReadOnly(folder); + } + + private boolean canCreateInside(OCFile folder) { + return folder.canCreateFileAndFolder() && !isReadOnlyFolder(folder); + } + protected void setAdapter(Bundle args) { boolean hideItemOptions = args != null && args.getBoolean(ARG_HIDE_ITEM_OPTIONS, false); boolean isGridViewPreferred = false; @@ -545,26 +552,19 @@ public void registerFabListener() { @Override public void createFolder(boolean encrypted) { - final var activity = getActivity(); - if (activity == null) { - Log_OC.e(TAG, "activity is null, cannot create a folder"); + if (encrypted) { + clickHandler.onNewEncryptedFolder(); return; } - if (encrypted) { - User user = accountManager.getUser(); - String publicKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PUBLIC_KEY); - String privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY); - - if (publicKey.isEmpty() || privateKey.isEmpty()) { - Log_OC.w(TAG,"cannot create encrypted folder directly, needs to setup encryption first"); + showCreateFolderDialog(false); + } - activity.runOnUiThread(() -> { - final var dialog = SetupEncryptionDialogFragment.newInstance(user, mFile.getRemotePath()); - dialog.show(getParentFragmentManager(), SETUP_ENCRYPTION_DIALOG_TAG); - }); - return; - } + public void showCreateFolderDialog(boolean encrypted) { + final var activity = getActivity(); + if (activity == null) { + Log_OC.e(TAG, "activity is null, cannot create a folder"); + return; } CreateFolderDialogFragment.newInstance(mFile, encrypted) @@ -1140,56 +1140,7 @@ private void folderOnItemClick(OCFile file, int position) { } if (file.isEncrypted()) { - User user = ((FileActivity) mContainerActivity).getUser().orElseThrow(RuntimeException::new); - - // check if e2e app is enabled - OCCapability ocCapability = mContainerActivity.getStorageManager() - .getCapability(user.getAccountName()); - - if (ocCapability.getEndToEndEncryption().isFalse() || - ocCapability.getEndToEndEncryption().isUnknown()) { - - if (getRecyclerView() != null) { - Snackbar.make(getRecyclerView(), R.string.end_to_end_encryption_not_enabled, - Snackbar.LENGTH_LONG).show(); - } - - return; - } - // check if keys are stored - if (FileOperationsHelper.isEndToEndEncryptionSetup(requireContext(), user)) { - // update state and view of this fragment - searchFragment = false; - mHideFab = false; - - if (mContainerActivity instanceof FolderPickerActivity && - ((FolderPickerActivity) mContainerActivity) - .isDoNotEnterEncryptedFolder()) { - - if (getRecyclerView() != null) { - Snackbar.make(getRecyclerView(), - R.string.copy_move_to_encrypted_folder_not_supported, - Snackbar.LENGTH_LONG).show(); - } - } else { - browseToFolder(file, position); - } - } else { - Log_OC.d(TAG, "no public key for " + user.getAccountName()); - - FragmentManager fragmentManager = getParentFragmentManager(); - if (fragmentManager.findFragmentByTag(SETUP_ENCRYPTION_DIALOG_TAG) == null && requireActivity() instanceof FileActivity fileActivity) { - fileActivity.connectivityService.isNetworkAndServerAvailable(result -> { - if (result) { - SetupEncryptionDialogFragment dialog = SetupEncryptionDialogFragment.newInstance(user, file.getRemotePath()); - dialog.show(fragmentManager, SETUP_ENCRYPTION_DIALOG_TAG); - } else { - DisplayUtils.showSnackMessage(fileActivity, R.string.internet_connection_required_for_encrypted_folder_setup); - } - return Unit.INSTANCE; - }); - } - } + clickHandler.onEncryptedFolderClick(file, position); } else { // update state and view of this fragment searchFragment = false; @@ -1324,7 +1275,7 @@ public OCFileDepth getFileDepth() { return fileDepth; } - private void browseToFolder(OCFile file, int position) { + protected void browseToFolder(OCFile file, int position) { setFileDepth(file); if (currentSearchType == FAVORITE_SEARCH) { @@ -1338,52 +1289,6 @@ private void browseToFolder(OCFile file, int position) { saveIndexAndTopPosition(position); } - private void listenSetupEncryptionDialogResult() { - getParentFragmentManager().setFragmentResultListener( - SetupEncryptionDialogFragment.RESULT_REQUEST_KEY, - this, - (requestKey, bundle) -> { - boolean result = bundle.getBoolean(SetupEncryptionDialogFragment.SUCCESS, false); - if (!result) { - Log_OC.d(TAG, "setup encryption dialog is dismissed"); - boolean cancelled = bundle.getBoolean(SetupEncryptionDialogFragment.RESULT_KEY_CANCELLED, false); - if (cancelled) { - browseToRoot(); - } - return; - } - - String fileRemotePath = bundle.getString(SetupEncryptionDialogFragment.ARG_FILE_PATH, null); - if (fileRemotePath == null) { - Log_OC.e(TAG, "file path is null"); - return; - } - - OCFile file = mContainerActivity.getStorageManager().getFileByDecryptedRemotePath(fileRemotePath); - if (file == null) { - Log_OC.e(TAG,"file is null, cannot toggle encryption"); - return; - } - - if (file.isRootDirectory()) { - Log_OC.d(TAG, "result of setup encryption triggered in root directory, this call is for " + - "creating encrypted folder"); - createFolder(true); - return; - } - - mContainerActivity.getFileOperationsHelper().toggleEncryption(file, true); - mAdapter.updateFileEncryptionById(file.getRemoteId(), true); - searchFragment = false; - setFileDepth(file); - listDirectory(file, MainApp.isOnlyOnDevice()); - mContainerActivity.onBrowsedDownTo(file); - - int position = mAdapter.getItemPosition(file); - saveIndexAndTopPosition(position); - }); - } - /** * Start the appropriate action(s) on the currently selected files given menu selected by the user. * @@ -1582,7 +1487,7 @@ public void refreshDirectory() { searchFragment = false; if (mFile != null) { - setFabVisible(mFile.canCreateFileAndFolder()); + setFabVisible(canCreateInside(mFile)); } final var currentFile = getCurrentFile(); @@ -1698,9 +1603,10 @@ private void updateLayout() { updateSortButton(); setLayoutSwitchButton(); - setFabVisible(!mHideFab); + boolean readOnly = isReadOnlyFolder(mFile); + setFabVisible(!mHideFab && !readOnly); slideHideBottomBehaviourForBottomNavigationView(!mHideFab); - setFabEnabled(mFile != null && (mFile.canCreateFileAndFolder() || mFile.isOfflineOperation())); + setFabEnabled(mFile != null && !readOnly && (mFile.canCreateFileAndFolder() || mFile.isOfflineOperation())); invalidateActionMode(); } @@ -1813,7 +1719,7 @@ public void onMessageEvent(ChangeMenuEvent changeMenuEvent) { } if (mFile != null) { - setFabVisible(mFile.canCreateFileAndFolder()); + setFabVisible(canCreateInside(mFile)); } slideHideBottomBehaviourForBottomNavigationView(true); @@ -1967,160 +1873,7 @@ private RemoteOperation getRecentFilesSearchRemoteOperation() { @Subscribe(threadMode = ThreadMode.MAIN) public void onMessageEvent(EncryptionEvent event) { - new Thread(() -> {{ - final User user = accountManager.getUser(); - - // check if keys are stored - String publicKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PUBLIC_KEY); - String privateKey = arbitraryDataProvider.getValue(user, EncryptionUtils.PRIVATE_KEY); - - FileDataStorageManager storageManager = mContainerActivity.getStorageManager(); - OCFile file = storageManager.getFileByRemoteId(event.getRemoteId()); - - if (publicKey.isEmpty() || privateKey.isEmpty()) { - Log_OC.d(TAG, "no public key for " + user.getAccountName()); - - - requireActivity().runOnUiThread(() -> { - SetupEncryptionDialogFragment dialog = SetupEncryptionDialogFragment.newInstance(user, file.getRemotePath()); - dialog.show(getParentFragmentManager(), SETUP_ENCRYPTION_DIALOG_TAG); - }); - } else { - // TODO E2E: if encryption fails, to not set it as encrypted! - encryptFolder(file, - event.getLocalId(), - event.getRemoteId(), - event.getRemotePath(), - event.getShouldBeEncrypted(), - publicKey, - privateKey, - storageManager); - } - }}).start(); - } - - private void encryptFolder(OCFile folder, - long localId, - String remoteId, - String remotePath, - boolean shouldBeEncrypted, - String publicKeyString, - String privateKeyString, - FileDataStorageManager storageManager) { - try { - Log_OC.d(TAG, "encrypt folder " + folder.getRemoteId()); - User user = accountManager.getUser(); - OwnCloudClient client = clientFactory.create(user); - final var remoteOperationResult = new ToggleEncryptionRemoteOperation(localId, - remotePath, - shouldBeEncrypted) - .execute(client); - - if (remoteOperationResult.isSuccess()) { - OCCapability ocCapability = mContainerActivity.getStorageManager().getCapability(user.getAccountName()); - final var isE2EEV2 = E2EVersionHelper.INSTANCE.isV2Plus(ocCapability); - long e2eCounter = EncryptionUtils.E2E_V1_INITIAL_COUNTER; - if (isE2EEV2) { - e2eCounter = EncryptionUtils.E2E_V2_INITIAL_COUNTER; - } - - // lock folder - String token = EncryptionUtils.lockFolder(folder, client, e2eCounter); - - if (E2EVersionHelper.INSTANCE.isV2Plus(ocCapability)) { - // Update metadata - Pair metadataPair = EncryptionUtils.retrieveMetadata(folder, - client, - privateKeyString, - publicKeyString, - storageManager, - user, - requireContext(), - arbitraryDataProvider); - - boolean metadataExists = metadataPair.first; - DecryptedFolderMetadataFile metadata = metadataPair.second; - - new EncryptionUtilsV2().serializeAndUploadMetadata(folder, - metadata, - token, - client, - metadataExists, - requireContext(), - user, - storageManager); - - // unlock folder - EncryptionUtils.unlockFolder(folder, client, token); - - - } else if (E2EVersionHelper.INSTANCE.isV1(ocCapability)) { - // new metadata - String publicKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); - - DecryptedFolderMetadataFileV1 metadata = new DecryptedFolderMetadataFileV1(); - metadata.setMetadata(new DecryptedMetadata()); - - final var e2eeVersion = storageManager.getE2EEVersionObject(user); - final var e2eeVersionAsString = e2eeVersion.getValue(); - metadata.getMetadata().setVersion(Double.parseDouble(e2eeVersionAsString)); - - metadata.getMetadata().setMetadataKeys(new HashMap<>()); - String metadataKey = EncryptionUtils.encodeBytesToBase64String(EncryptionUtils.generateKey()); - String encryptedMetadataKey = EncryptionUtils.encryptStringAsymmetric(metadataKey, publicKey); - metadata.getMetadata().setMetadataKey(encryptedMetadataKey); - - EncryptedFolderMetadataFileV1 encryptedFolderMetadata = EncryptionUtils.encryptFolderMetadata(metadata, - publicKey, - folder.getLocalId(), - user, - arbitraryDataProvider - ); - String serializedFolderMetadata = EncryptionUtils.serializeJSON(encryptedFolderMetadata); - - // upload metadata - EncryptionUtils.uploadMetadata(folder, - serializedFolderMetadata, - token, - client, - false, - e2eeVersion, - "", - arbitraryDataProvider, - user); - - // unlock folder - EncryptionUtils.unlockFolderV1(folder, client, token); - } else if (ocCapability.getEndToEndEncryptionApiVersion() == E2EVersion.UNKNOWN) { - throw new IllegalArgumentException("Unknown E2E version"); - } - - requireActivity().runOnUiThread(() -> { - boolean isFileExists = (mAdapter.getFileByRemoteId(remoteId) != null); - if (!isFileExists) { - OCFile newFile = storageManager.getFileByRemoteId(remoteId); - mAdapter.insertFile(newFile); - } - - mAdapter.updateFileEncryptionById(remoteId, shouldBeEncrypted); - }); - } else if (remoteOperationResult.getHttpCode() == HttpStatus.SC_FORBIDDEN && getRecyclerView() != null) { - requireActivity().runOnUiThread(() -> Snackbar.make(getRecyclerView(), - R.string.end_to_end_encryption_folder_not_empty, - Snackbar.LENGTH_LONG).show()); - } else { - requireActivity().runOnUiThread(() -> {{ - if (getRecyclerView() != null) { - Snackbar.make(getRecyclerView(), - R.string.common_error_unknown, - Snackbar.LENGTH_LONG).show(); - } - }}); - } - - } catch (Throwable e) { - Log_OC.e(TAG, "Error creating encrypted folder", e); - } + event.onResult(this); } @Subscribe(threadMode = ThreadMode.BACKGROUND) diff --git a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java index a46a61f86edf..5b01b7693136 100755 --- a/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java +++ b/app/src/main/java/com/owncloud/android/ui/helpers/FileOperationsHelper.java @@ -37,6 +37,7 @@ import com.nextcloud.client.jobs.upload.FileUploadWorker; import com.nextcloud.client.network.ConnectivityService; import com.nextcloud.utils.EditorUtils; +import com.nextcloud.utils.extensions.OCFileExtensionsKt; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.datamodel.ArbitraryDataProvider; @@ -944,10 +945,7 @@ public void toggleEncryption(OCFile file, boolean shouldBeEncrypted) { } Log_OC.d(TAG, "toggling encryption for: " + file.getRemotePath()); - EventBus.getDefault().post(new EncryptionEvent(file.getLocalId(), - file.getRemoteId(), - file.getRemotePath(), - shouldBeEncrypted)); + EventBus.getDefault().post(OCFileExtensionsKt.toEncryptionEvent(file, shouldBeEncrypted)); } public void toggleFileLock(OCFile file, boolean shouldBeLocked) { diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index c689415f3042..a678575caba3 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -504,6 +504,39 @@ public static String getData(InputStream inputStream) { } // region snackbar + public static Snackbar createAndShowSnackMessage(Fragment fragment, @StringRes int messageResource) { + if (fragment == null) { + Log_OC.e(TAG, "snackbar cannot be shown fragment is null"); + return null; + } + + final var activity = fragment.getActivity(); + if (activity == null) { + Log_OC.e(TAG, "snackbar cannot be shown activity is null"); + return null; + } + + final var snackbar = Snackbar.make( + activity.findViewById(android.R.id.content), + messageResource, + Snackbar.LENGTH_INDEFINITE); + + var fab = findFABView(activity); + if (fab != null && fab.getVisibility() == View.VISIBLE) { + snackbar.setAnchorView(fab); + } + + mainLooper.post(snackbar::show); + return snackbar; + } + + public static void dismissSnackMessage(@Nullable Snackbar snackbar) { + if (snackbar == null) { + return; + } + mainLooper.post(snackbar::dismiss); + } + public static void showSnackMessage(Fragment fragment, @StringRes int messageResource) { if (fragment == null) { Log_OC.e(TAG, "snackbar cannot be shown fragment is null"); diff --git a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java index 0a77ebdd0b6b..42fca11c449f 100644 --- a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java @@ -425,12 +425,16 @@ public static DecryptedFolderMetadataFileV1 decryptFolderMetaData(EncryptedFolde E2EVersion version = E2EVersionHelper.INSTANCE.fromMetadata(serializedEncryptedMetadata); if (E2EVersionHelper.INSTANCE.isV2Plus(version)) { - EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); - return encryptionUtilsV2.parseAnyMetadata(getMetadataOperationResult.getResultData(), - user, - client, - context, - folder); + try { + return new EncryptionUtilsV2().parseAnyMetadata(getMetadataOperationResult.getResultData(), + user, + client, + context, + folder); + } catch (Exception e) { + Log_OC.e(TAG, "Could not decrypt metadata for " + folder.getDecryptedFileName(), e); + return null; + } } else if (E2EVersionHelper.INSTANCE.isV1(version)) { ArbitraryDataProvider arbitraryDataProvider = new ArbitraryDataProviderImpl(context); String privateKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PRIVATE_KEY); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f21f2174191..4acb64b38f68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,4 @@ - -