diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/101.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/101.json new file mode 100644 index 000000000000..b98265e2ee44 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/101.json @@ -0,0 +1,1313 @@ +{ + "formatVersion": 1, + "database": { + "version": 101, + "identityHash": "142ec7d5c0ae4ed96ff46d3e681144d2", + "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)", + "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" + } + ], + "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)", + "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" + } + ], + "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, '142ec7d5c0ae4ed96ff46d3e681144d2')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt index da634d4d62c6..94fe57651bc5 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt @@ -15,7 +15,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot import com.nextcloud.test.TestActivity -import com.nextcloud.ui.ImageDetailFragment +import com.nextcloud.ui.fileInfo.FileInfoFragment import com.owncloud.android.AbstractIT import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile @@ -79,7 +79,7 @@ class FileDetailFragmentStaticServerIT : AbstractIT() { var activity: TestActivity? = null scenario.onActivity { sut -> activity = sut - val fragment = ImageDetailFragment.newInstance(oCFile, user).apply { + val fragment = FileInfoFragment.newInstance(oCFile, user).apply { hideMap() } sut.addFragment(fragment) 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 39fa5c7b33ae..0cc09011b882 100644 --- a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt +++ b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt @@ -40,6 +40,7 @@ import com.nextcloud.client.database.entity.VirtualEntity import com.nextcloud.client.database.migrations.DatabaseMigrationUtil import com.nextcloud.client.database.migrations.MIGRATION_88_89 import com.nextcloud.client.database.migrations.MIGRATION_97_98 +import com.nextcloud.client.database.migrations.MIGRATION_100_101 import com.nextcloud.client.database.migrations.MIGRATION_99_100 import com.nextcloud.client.database.migrations.Migration67to68 import com.nextcloud.client.database.migrations.RoomMigration @@ -99,6 +100,7 @@ import com.owncloud.android.db.ProviderMeta // manual migration used for 97 to 98 AutoMigration(from = 98, to = 99) // manual migration used for 99 to 100 + // manual migration used for 100 to 101 ], exportSchema = true ) @@ -139,6 +141,7 @@ abstract class NextcloudDatabase : RoomDatabase() { .addMigrations(MIGRATION_88_89) .addMigrations(MIGRATION_97_98) .addMigrations(MIGRATION_99_100) + .addMigrations(MIGRATION_100_101) .build() } return instance!! diff --git a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt index eb225bab7972..feb27a658a02 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt @@ -126,6 +126,8 @@ data class CapabilityEntity( val dropAccount: Int?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SECURITY_GUARD) val securityGuard: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_GOVERNANCE) + val governance: Int?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS) val forbiddenFileNameCharacters: String?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES) @@ -220,6 +222,7 @@ fun CapabilityEntity?.toOCCapability(): OCCapability { capability.groupfolders = intToBoolean(this.groupfolders) capability.dropAccount = intToBoolean(this.dropAccount) capability.securityGuard = intToBoolean(this.securityGuard) + capability.governance = intToBoolean(this.governance) capability.forbiddenFilenameCharactersJson = this.forbiddenFileNameCharacters capability.forbiddenFilenamesJson = this.forbiddenFileNames capability.forbiddenFilenameExtensionJson = this.forbiddenFileNameExtensions diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/Migration100to101.kt b/app/src/main/java/com/nextcloud/client/database/migrations/Migration100to101.kt new file mode 100644 index 000000000000..e4b5232859d1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/Migration100to101.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +@Suppress("MagicNumber", "LongMethod") +val MIGRATION_100_101 = object : Migration(100, 101) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS capabilities") + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS capabilities ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + 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_ask_for_optional_password 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, + files_locking_version TEXT, + external_links INTEGER, + server_name TEXT, + server_color TEXT, + server_text_color TEXT, + server_element_color TEXT, + background_url TEXT, + server_slogan TEXT, + server_logo TEXT, + background_default INTEGER, + background_plain INTEGER, + end_to_end_encryption INTEGER, + end_to_end_encryption_keys_exist INTEGER, + end_to_end_encryption_api_version TEXT, + activity INTEGER, + richdocument INTEGER, + recommendation INTEGER, + richdocument_mimetype_list TEXT, + richdocument_optional_mimetype_list TEXT, + richdocument_direct_editing INTEGER, + richdocument_direct_templates INTEGER, + richdocument_product_name TEXT, + direct_editing_etag TEXT, + etag TEXT, + user_status INTEGER, + user_status_supports_emoji INTEGER, + user_status_supports_busy INTEGER, + assistant INTEGER, + 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, + windows_compatible_filenames INTEGER, + files_download_limit INTEGER, + files_download_limit_default INTEGER, + notes_folder_path TEXT, + default_permissions INTEGER, + has_valid_subscription INTEGER, + client_integration_json TEXT + ) + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 42be74854cca..2643b10e530a 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -29,7 +29,7 @@ import com.nextcloud.receiver.NetworkChangeReceiver; import com.nextcloud.ui.ChooseAccountDialogFragment; import com.nextcloud.ui.ChooseStorageLocationDialogFragment; -import com.nextcloud.ui.ImageDetailFragment; +import com.nextcloud.ui.fileInfo.FileInfoFragment; import com.nextcloud.ui.SetOnlineStatusBottomSheet; import com.nextcloud.ui.SetStatusMessageBottomSheet; import com.nextcloud.ui.composeActivity.ComposeActivity; @@ -487,7 +487,7 @@ abstract class ComponentsModule { abstract EditImageActivity editImageActivity(); @ContributesAndroidInjector - abstract ImageDetailFragment imageDetailFragment(); + abstract FileInfoFragment fileInfoFragment(); @ContributesAndroidInjector abstract EtmBackgroundJobsFragment etmBackgroundJobsFragment(); diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index eb3e98a6c570..c0a3c1a85927 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.ViewModelProvider import com.nextcloud.client.documentscan.DocumentScanViewModel import com.nextcloud.client.etm.EtmViewModel import com.nextcloud.client.logger.ui.LogsViewModel +import com.nextcloud.ui.fileInfo.FileInfoViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel @@ -57,6 +58,11 @@ abstract class ViewModelModule { @ViewModelKey(TrashbinFileActionsViewModel::class) abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(FileInfoViewModel::class) + abstract fun fileInfoViewModel(vm: FileInfoViewModel): ViewModel + @Binds abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory } diff --git a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt deleted file mode 100644 index 766809e8247c..000000000000 --- a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt +++ /dev/null @@ -1,398 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 ZetaTom - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.ui - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.graphics.drawable.LayerDrawable -import android.os.Bundle -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.VisibleForTesting -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.client.NominatimClient -import com.nextcloud.client.account.User -import com.nextcloud.client.di.Injectable -import com.nextcloud.utils.extensions.getParcelableArgument -import com.nextcloud.utils.extensions.logFileSize -import com.owncloud.android.MainApp -import com.owncloud.android.R -import com.owncloud.android.databinding.PreviewImageDetailsFragmentBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.datamodel.ThumbnailsCacheManager -import com.owncloud.android.utils.BitmapUtils -import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.theme.ViewThemeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.overlay.ItemizedIconOverlay -import org.osmdroid.views.overlay.ItemizedIconOverlay.OnItemGestureListener -import org.osmdroid.views.overlay.OverlayItem -import java.lang.Long.max -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Locale -import javax.inject.Inject -import kotlin.math.pow -import kotlin.math.roundToInt - -class ImageDetailFragment : - Fragment(), - Injectable { - private lateinit var binding: PreviewImageDetailsFragmentBinding - private lateinit var file: OCFile - private lateinit var user: User - private lateinit var metadata: ImageMetadata - private lateinit var nominatimClient: NominatimClient - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private val tag = "ImageDetailFragment" - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = PreviewImageDetailsFragmentBinding.inflate(layoutInflater, container, false) - - binding.fileDetailsIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_image_24, - ColorRole.ON_BACKGROUND - ) - ) - - binding.cameraInformationIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_camera_24, - ColorRole.ON_BACKGROUND - ) - ) - - val arguments = arguments ?: throw IllegalStateException("arguments are mandatory") - file = arguments.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = arguments.getParcelableArgument(ARG_USER, User::class.java)!! - - if (savedInstanceState != null) { - file = savedInstanceState.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = savedInstanceState.getParcelableArgument(ARG_USER, User::class.java)!! - metadata = savedInstanceState.getParcelableArgument(ARG_METADATA, ImageMetadata::class.java)!! - } - - nominatimClient = NominatimClient( - getString(R.string.osm_geocoder_url), - getString(R.string.osm_geocoder_contact) - ) - - return binding.root - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - file.logFileSize(tag) - outState.putParcelable(ARG_FILE, file) - outState.putParcelable(ARG_USER, user) - outState.putParcelable(ARG_METADATA, metadata) - } - - override fun onStart() { - super.onStart() - gatherMetadata() - setupFragment() - } - - @SuppressLint("LongMethod") - private fun setupFragment() { - binding.fileInformationTime.text = metadata.date - - // detailed file information - val fileInformation = mutableListOf() - if ((metadata.length ?: 0) > 0 && (metadata.width ?: 0) > 0) { - try { - @Suppress("MagicNumber") - val pxlCount = when (val res = metadata.length!! * metadata.width!!.toLong()) { - in 0..999999 -> "%.2f".format(res / 1000000f) - in 1000000..9999999 -> "%.1f".format(res / 1000000f) - else -> (res / 1000000).toString() - } - - fileInformation.add(String.format(getString(R.string.image_preview_unit_megapixel), pxlCount)) - fileInformation.add("${metadata.width!!} × ${metadata.length!!}") - } catch (_: NumberFormatException) { - } - } - metadata.fileSize?.let { fileInformation.add(it) } - - if (fileInformation.isNotEmpty()) { - binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP) - binding.fileInformation.visibility = View.VISIBLE - } - - setImageTakenConditions() - - // initialise map and address views - metadata.location?.let { location -> - initMap(location.first, location.second) - binding.imageLocation.visibility = View.VISIBLE - - // launch reverse geocoding request - lifecycleScope.launch(Dispatchers.IO) { - val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second) - if (geocodingResult != null) { - withContext(Dispatchers.Main) { - binding.imageLocationText.visibility = View.VISIBLE - binding.imageLocationText.text = geocodingResult.displayName - } - } - } - } - } - - private fun setImageTakenConditions() { - // camera make and model - val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) { - "${metadata.make} ${metadata.model}" - } else { - metadata.model ?: metadata.make - } - - if (metadata.make == null || metadata.model?.contains(metadata.make!!) == true) { - binding.imgTCMakeModel.text = metadata.model - } else { - binding.imgTCMakeModel.text = String.format( - getString(R.string.make_model), - metadata.make, - metadata.model - ) - } - - // image taking conditions - val imageTakingConditions = mutableListOf() - metadata.aperture?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_fnumber), it)) - } - metadata.exposure?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_seconds), it)) - } - metadata.focalLen?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_millimetres), it)) - } - metadata.iso?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_iso), it)) - } - - if (imageTakingConditions.isNotEmpty() && makeModel != null) { - binding.imgTCMakeModel.text = makeModel - binding.imgTCConditions.text = imageTakingConditions.joinToString(separator = TEXT_SEP) - binding.imgTC.visibility = View.VISIBLE - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun initMap(latitude: Double, longitude: Double, zoom: Double = 13.0) { - // required for OpenStreetMap - Configuration.getInstance().userAgentValue = MainApp.getUserAgent() - - val location = GeoPoint(latitude, longitude) - - binding.imageLocationMap.apply { - setTileSource(TileSourceFactory.MAPNIK) - - // set expected boundaries - setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) - isVerticalMapRepetitionEnabled = false - minZoomLevel = 2.0 - maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() - - // initial location - controller.setCenter(location) - controller.setZoom(zoom) - - // scale labels to be legible - isTilesScaledToDpi = true - setZoomRounding(true) - - // hide zoom buttons - zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) - - // enable multi-touch zoom - setMultiTouchControls(true) - setOnTouchListener { v, _ -> - v.parent.requestDisallowInterceptTouchEvent(true) - false - } - - val markerOverlay = ItemizedIconOverlay( - mutableListOf(OverlayItem(null, null, location)), - imagePinDrawable(context), - markerOnGestureListener(latitude, longitude), - context - ) - - overlays.add(markerOverlay) - - onResume() - } - - // add copyright notice - binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice - } - - @VisibleForTesting - fun hideMap() { - binding.imageLocationMap.visibility = View.GONE - } - - @SuppressLint("SimpleDateFormat") - private fun gatherMetadata() { - val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) - var timestamp = max(file.modificationTimestamp, file.creationTimestamp) - if (file.isDown) { - val exif = androidx.exifinterface.media.ExifInterface(file.storagePath) - var length = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_LENGTH)?.toInt() - var width = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_WIDTH)?.toInt() - var exposure = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_SHUTTER_SPEED_VALUE) - - // get timestamp from date string - exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_DATETIME)?.let { - timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp - } - - // format exposure string - if (exposure == null) { - exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_EXPOSURE_TIME)?.let { - exposure = "1/" + (1 / it.toDouble()).toInt() - } - } else if ("/" in exposure!!) { - try { - exposure!!.split("/").also { - exposure = "1/" + 2f.pow(it[0].toFloat() / it[1].toFloat()).roundToInt() - } - } catch (_: NumberFormatException) { - } - } - - // determine size if not contained in exif data - if ((width ?: 0) <= 0 || (length ?: 0) <= 0) { - val res = BitmapUtils.getImageResolution(file.storagePath) - width = res[0] - length = res[1] - } - - metadata = ImageMetadata( - fileSize = fileSize, - length = length, - width = width, - exposure = exposure, - date = formatDate(timestamp), - location = exif.latLong?.let { Pair(it[0], it[1]) }, - aperture = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_F_NUMBER), - focalLen = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), - make = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MAKE), - model = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MODEL), - iso = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_ISO_SPEED) ?: exif.getAttribute( - androidx.exifinterface.media.ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY - ) - ) - } else { - // get metadata from server - val location = if (file.geoLocation == null) { - null - } else { - Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) - } - metadata = ImageMetadata( - fileSize = fileSize, - date = formatDate(timestamp), - location = location, - width = file.imageDimension?.width?.toInt(), - length = file.imageDimension?.height?.toInt() - ) - } - } - - @SuppressLint("SimpleDateFormat") - private fun formatDate(timestamp: Long): String = buildString { - append(SimpleDateFormat("EEEE").format(timestamp)) - append(TEXT_SEP) - append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) - append(TEXT_SEP) - append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) - } - - private fun imagePinDrawable(context: Context): LayerDrawable { - val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable - - val bitmap = - ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) - BitmapUtils.bitmapToCircularBitmapDrawable(resources, bitmap)?.let { - drawable.setDrawable(1, it) - } - - return drawable - } - - /** - * OnItemGestureListener for marker in MapView. - */ - private fun markerOnGestureListener(latitude: Double, longitude: Double) = - object : OnItemGestureListener { - override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { - val intent = Intent(Intent.ACTION_VIEW, "geo:0,0?q=$latitude,$longitude".toUri()) - DisplayUtils.startIntentIfAppAvailable(intent, activity, R.string.no_map_app_availble) - return true - } - - override fun onItemLongPress(index: Int, item: OverlayItem): Boolean = false - } - - @Parcelize - private data class ImageMetadata( - val fileSize: String? = null, - val date: String? = null, - val length: Int? = null, - val width: Int? = null, - val exposure: String? = null, - val aperture: String? = null, - val focalLen: String? = null, - val iso: String? = null, - val make: String? = null, - val model: String? = null, - val location: Pair? = null - ) : Parcelable - - companion object { - private const val ARG_FILE = "FILE" - private const val ARG_USER = "USER" - private const val ARG_METADATA = "METADATA" - private const val TEXT_SEP = " • " - private const val SCROLL_LIMIT = 80.0 - - @JvmStatic - fun newInstance(file: OCFile, user: User): ImageDetailFragment = ImageDetailFragment().apply { - arguments = Bundle().apply { - putParcelable(ARG_FILE, file) - putParcelable(ARG_USER, user) - } - } - } -} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt new file mode 100644 index 000000000000..d3c792745275 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -0,0 +1,101 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.core.os.BundleCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import com.nextcloud.client.account.User +import com.nextcloud.client.di.Injectable +import com.nextcloud.client.di.ViewModelFactory +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.CapabilityUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class FileInfoFragment : + Fragment(), + Injectable { + private lateinit var binding: FileInfoFragmentBinding + + val file: OCFile? by lazy { + arguments?.let { BundleCompat.getParcelable(it, ARG_FILE, OCFile::class.java) } + } + + val user: User? by lazy { + arguments?.let { BundleCompat.getParcelable(it, ARG_USER, User::class.java) } + } + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var vmFactory: ViewModelFactory + + private lateinit var viewModel: FileInfoViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProvider(this, vmFactory)[FileInfoViewModel::class.java] + val file = file ?: return + val user = user ?: return + viewModel.load(file, user) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) + + if (MimeTypeUtil.isImage(file)) { + val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) + file?.let { imageDetailInfo.init(it, binding) } + } + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + if (CapabilityUtils.getCapability(context).governance.isTrue) { + val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this, viewModel) + governanceDetailInfo.init() + } else { + binding.governanceLayout.visibility = View.GONE + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.run { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + } + } + + @VisibleForTesting + fun hideMap() { + binding.imageLocationMap.visibility = View.GONE + } + + companion object { + private const val ARG_FILE = "FILE" + private const val ARG_USER = "USER" + + fun newInstance(file: OCFile, user: User): FileInfoFragment = FileInfoFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt new file mode 100644 index 000000000000..267d76632e09 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt @@ -0,0 +1,141 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.android.lib.resources.governance.EntityLabels +import com.nextcloud.android.lib.resources.governance.GetAvailableRetentionLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.GetAvailableSensitivityLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.GetEntityLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.LabelType +import com.nextcloud.android.lib.resources.governance.RemoveLabelRemoteOperation +import com.nextcloud.android.lib.resources.governance.RetentionLabelInfo +import com.nextcloud.android.lib.resources.governance.SensitivityLabelInfo +import com.nextcloud.android.lib.resources.governance.SetLabelRemoteOperation +import com.nextcloud.client.account.User +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.ui.fileInfo.model.GovernanceLabel +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class FileInfoViewModel @Inject constructor( + private val clientFactory: ClientFactory +) : ViewModel() { + + private val _sensitivityLabels = MutableStateFlow?>(null) + val sensitivityLabels: StateFlow?> = _sensitivityLabels + + private val _retentionLabels = MutableStateFlow?>(null) + val retentionLabels: StateFlow?> = _retentionLabels + + private val _currentSensitivityLabelId = MutableStateFlow(null) + val currentSensitivityLabelId: StateFlow = _currentSensitivityLabelId + + private var cachedFile: OCFile? = null + private var cachedUser: User? = null + + fun load(file: OCFile, user: User) { + cachedFile = file + cachedUser = user + viewModelScope.launch(Dispatchers.IO) { + _sensitivityLabels.value = fetchSensitivityLabels(file, user) + } + viewModelScope.launch(Dispatchers.IO) { + _retentionLabels.value = fetchRetentionLabels(file, user) + } + viewModelScope.launch(Dispatchers.IO) { + _currentSensitivityLabelId.value = fetchEntityLabels(file, user)?.sensitivity?.id ?: "" + } + } + + fun setSensitivityLabel(labelId: String) { + val file = cachedFile ?: return + val user = cachedUser ?: return + viewModelScope.launch(Dispatchers.IO) { + val result = applyLabel(file, user, LabelType.SENSITIVITY, labelId) + if (result) { + _currentSensitivityLabelId.value = labelId + } + } + } + + fun removeSensitivityLabel() { + val labelId = _currentSensitivityLabelId.value?.takeIf { it.isNotEmpty() } ?: return + val file = cachedFile ?: return + val user = cachedUser ?: return + viewModelScope.launch(Dispatchers.IO) { + val result = removeLabel(file, user, LabelType.SENSITIVITY, labelId) + if (result) { + _currentSensitivityLabelId.value = "" + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun fetchSensitivityLabels(file: OCFile, user: User): List = try { + val client = clientFactory.createNextcloudClient(user) + val result = GetAvailableSensitivityLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) + if (result.isSuccess) result.resultData.orEmpty().map { it.toGovernanceLabel() } else emptyList() + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch available sensitivity labels", e) + emptyList() + } + + @Suppress("TooGenericExceptionCaught") + private fun fetchRetentionLabels(file: OCFile, user: User): List = try { + val client = clientFactory.createNextcloudClient(user) + val result = GetAvailableRetentionLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) + if (result.isSuccess) result.resultData.orEmpty().map { it.toGovernanceLabel() } else emptyList() + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch available retention labels", e) + emptyList() + } + + @Suppress("TooGenericExceptionCaught") + private fun fetchEntityLabels(file: OCFile, user: User): EntityLabels? = try { + val client = clientFactory.createNextcloudClient(user) + val result = GetEntityLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId.toString()).execute(client) + if (result.isSuccess) result.resultData else null + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch entity labels", e) + null + } + + @Suppress("TooGenericExceptionCaught") + private fun applyLabel(file: OCFile, user: User, labelType: LabelType, labelId: String): Boolean = try { + val client = clientFactory.createNextcloudClient(user) + SetLabelRemoteOperation(ENTITY_TYPE_FILES, file.localId, labelType, labelId).execute(client).isSuccess + } catch (e: Exception) { + Log_OC.e(TAG, "Could not set label", e) + false + } + + @Suppress("TooGenericExceptionCaught") + private fun removeLabel(file: OCFile, user: User, labelType: LabelType, labelId: String): Boolean = try { + val client = clientFactory.createNextcloudClient(user) + RemoveLabelRemoteOperation(ENTITY_TYPE_FILES, file.localId, labelType, labelId).execute(client).isSuccess + } catch (e: Exception) { + Log_OC.e(TAG, "Could not remove label", e) + false + } + + private fun SensitivityLabelInfo.toGovernanceLabel() = GovernanceLabel(id, name, color) + + private fun RetentionLabelInfo.toGovernanceLabel() = GovernanceLabel(id, name, color) + + companion object { + private val TAG = FileInfoViewModel::class.java.simpleName + private const val ENTITY_TYPE_FILES = "FILES" + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt new file mode 100644 index 000000000000..5f980e4a4a88 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -0,0 +1,146 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.graphics.toColorInt +import androidx.lifecycle.lifecycleScope +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.material.textfield.TextInputLayout +import com.nextcloud.ui.fileInfo.model.GovernanceLabel +import com.owncloud.android.R +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +class GovernanceDetailInfo( + private val binding: FileInfoFragmentBinding, + private val viewThemeUtils: ViewThemeUtils, + private val fragment: FileInfoFragment, + private val viewModel: FileInfoViewModel +) { + private val context get() = fragment.requireContext() + + fun init() { + viewThemeUtils.material.themeCardView(binding.governanceLayout) + collectSensitivityLabels() + collectRetentionLabels() + } + + private fun collectSensitivityLabels() { + fragment.lifecycleScope.launch { + val labels = viewModel.sensitivityLabels.filterNotNull().first() + if (labels.isEmpty()) { + binding.sensitivityLabel.visibility = View.GONE + return@launch + } + val currentId = viewModel.currentSensitivityLabelId.filterNotNull().first() + val items = listOf(GovernanceLabel("", "", "")) + labels + val initialItem = items.find { it.id == currentId } ?: items.first() + initDropdown( + textInputLayout = binding.sensitivityLabel, + autoComplete = binding.sensitivityLabelAutoComplete, + items = items, + initialItem = initialItem + ) { label -> + if (label.id.isEmpty()) { + viewModel.removeSensitivityLabel() + } else { + viewModel.setSensitivityLabel(label.id) + } + } + } + } + + private fun collectRetentionLabels() { + fragment.lifecycleScope.launch { + val labels = viewModel.retentionLabels.filterNotNull().first() + if (labels.isEmpty()) { + binding.fileRetentionLabel.visibility = View.GONE + return@launch + } + initDropdown( + textInputLayout = binding.fileRetentionLabel, + autoComplete = binding.fileRetentionAutoComplete, + items = labels + ) + } + } + + private fun initDropdown( + textInputLayout: TextInputLayout, + autoComplete: MaterialAutoCompleteTextView, + items: List, + initialItem: GovernanceLabel? = items.firstOrNull(), + onSelect: (GovernanceLabel) -> Unit = {} + ) { + textInputLayout.visibility = View.VISIBLE + viewThemeUtils.material.colorTextInputLayout(textInputLayout) + viewThemeUtils.files.themeAutoCompleteTextView(autoComplete) + + autoComplete.setAdapter(buildAdapter(items)) + initialItem?.let { applySelection(autoComplete, it) } + + autoComplete.setOnItemClickListener { _, _, position, _ -> + val item = items[position] + applySelection(autoComplete, item) + onSelect(item) + } + } + + private fun buildAdapter(items: List) = + object : ArrayAdapter(context, R.layout.item_dropdown_with_icon, items) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) as TextView + getItem(position)?.let { item -> + view.text = item.text + val dot = if (item.color.isNotEmpty()) colorDot(item.color) else null + view.setCompoundDrawablesWithIntrinsicBounds(dot, null, null, null) + } + return view + } + } + + private fun applySelection(autoComplete: MaterialAutoCompleteTextView, item: GovernanceLabel) { + autoComplete.setText(item.text, false) + if (item.color.isNotEmpty()) { + autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(colorDot(item.color), null, null, null) + autoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } else { + autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null) + } + } + + private fun colorDot(color: String): Drawable { + val size = context.resources.getDimensionPixelSize(R.dimen.governance_color_dot_size) + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setSize(size, size) + setColor(parseColor(color)) + } + } + + private fun parseColor(color: String): Int = runCatching { color.toColorInt() } + .getOrElse { + Log_OC.w(TAG, "Could not parse label color: $color") + ContextCompat.getColor(context, R.color.grey_600) + } + + companion object { + private val TAG = GovernanceDetailInfo::class.java.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt new file mode 100644 index 000000000000..00baceba14d9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -0,0 +1,280 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.drawable.LayerDrawable +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.NominatimClient +import com.nextcloud.ui.fileInfo.model.ImageMetadata +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.ItemizedIconOverlay +import org.osmdroid.views.overlay.OverlayItem +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.math.pow +import kotlin.math.roundToInt + +class ImageDetailInfo(private val fragment: FileInfoFragment, private val viewThemeUtils: ViewThemeUtils) { + + companion object { + private const val TEXT_SEP = " • " + private const val SCROLL_LIMIT = 80.0 + } + + fun init(file: OCFile, binding: FileInfoFragmentBinding) { + viewThemeUtils.material.themeCardView(binding.imageDetailLayout) + binding.imageDetailLayout.visibility = View.VISIBLE + + val metadata = gatherMetadata(file) + binding.fileInformationTime.text = metadata.date + binding.fileDetailsIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + fragment.requireContext(), + R.drawable.outline_image_24, + ColorRole.ON_BACKGROUND + ) + ) + + binding.cameraInformationIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + fragment.requireContext(), + R.drawable.outline_camera_24, + ColorRole.ON_BACKGROUND + ) + ) + + val fileInformation = buildList { + val length = metadata.length ?: 0 + val width = metadata.width ?: 0 + if (length > 0 && width > 0) { + runCatching { + @Suppress("MagicNumber") + val pxlCount = when (val res = length * width.toLong()) { + in 0..999_999 -> "%.2f".format(res / 1_000_000f) + in 1_000_000..9_999_999 -> "%.1f".format(res / 1_000_000f) + else -> (res / 1_000_000).toString() + } + add(fragment.getString(R.string.image_preview_unit_megapixel).format(pxlCount)) + add("$width × $length") + } + } + metadata.fileSize?.let { add(it) } + } + + if (fileInformation.isNotEmpty()) { + binding.fileInformationDetails.text = fileInformation.joinToString(TEXT_SEP) + binding.fileInformation.visibility = View.VISIBLE + } + + setImageTakenConditions(metadata, binding) + + metadata.location?.let { (lat, lon) -> + initMap(binding, file, lat, lon) + binding.imageLocation.visibility = View.VISIBLE + + fragment.lifecycleScope.launch(Dispatchers.IO) { + val nominatimClient = NominatimClient( + fragment.getString(R.string.osm_geocoder_url), + fragment.getString(R.string.osm_geocoder_contact) + ) + nominatimClient.reverseGeocode(lat, lon)?.let { result -> + withContext(Dispatchers.Main) { + binding.imageLocationText.text = result.displayName + binding.imageLocationText.visibility = View.VISIBLE + } + } + } + } + } + + private fun setImageTakenConditions(metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + val makeModel = when { + metadata.make == null -> metadata.model + metadata.model?.contains(metadata.make) == true -> metadata.model + else -> fragment.getString(R.string.make_model).format(metadata.make, metadata.model) + } + + val imageTakingConditions = buildList { + metadata.aperture?.let { add(fragment.getString(R.string.image_preview_unit_fnumber).format(it)) } + metadata.exposure?.let { add(fragment.getString(R.string.image_preview_unit_seconds).format(it)) } + metadata.focalLen?.let { add(fragment.getString(R.string.image_preview_unit_millimetres).format(it)) } + metadata.iso?.let { add(fragment.getString(R.string.image_preview_unit_iso).format(it)) } + } + + if (imageTakingConditions.isNotEmpty() && makeModel != null) { + binding.imgTCMakeModel.text = makeModel + binding.imgTCConditions.text = imageTakingConditions.joinToString(TEXT_SEP) + binding.imgTC.visibility = View.VISIBLE + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun initMap( + binding: FileInfoFragmentBinding, + file: OCFile, + latitude: Double, + longitude: Double, + zoom: Double = 13.0 + ) { + Configuration.getInstance().userAgentValue = MainApp.getUserAgent() + + val location = GeoPoint(latitude, longitude) + + binding.imageLocationMap.apply { + setTileSource(TileSourceFactory.MAPNIK) + setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) + isVerticalMapRepetitionEnabled = false + minZoomLevel = 2.0 + maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() + controller.setCenter(location) + controller.setZoom(zoom) + isTilesScaledToDpi = true + setZoomRounding(true) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setMultiTouchControls(true) + setOnTouchListener { v, _ -> + v.parent.requestDisallowInterceptTouchEvent(true) + false + } + overlays.add( + ItemizedIconOverlay( + mutableListOf(OverlayItem(null, null, location)), + imagePinDrawable(context, file), + markerOnGestureListener(latitude, longitude), + context + ) + ) + onResume() + } + + binding.imageLocationMapCopyright.text = + binding.imageLocationMap.tileProvider.tileSource.copyrightNotice + } + + fun gatherMetadata(file: OCFile): ImageMetadata { + val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) + val timestamp = maxOf(file.modificationTimestamp, file.creationTimestamp) + return if (file.isDown) { + gatherLocalMetadata(file, fileSize, timestamp) + } else { + gatherRemoteMetadata(file, fileSize, timestamp) + } + } + + private fun gatherLocalMetadata(file: OCFile, fileSize: String, fallbackTimestamp: Long): ImageMetadata { + val exif = ExifInterface(file.storagePath) + val timestamp = parseTimestamp(exif, fallbackTimestamp) + val (width, length) = parseImageDimensions(exif, file.storagePath) + + return ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + length = length, + width = width, + exposure = parseExposure(exif), + location = exif.latLong?.let { Pair(it[0], it[1]) }, + aperture = exif.getAttribute(ExifInterface.TAG_F_NUMBER), + focalLen = exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), + make = exif.getAttribute(ExifInterface.TAG_MAKE), + model = exif.getAttribute(ExifInterface.TAG_MODEL), + iso = exif.getAttribute(ExifInterface.TAG_ISO_SPEED) + ?: exif.getAttribute(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) + ) + } + + private fun gatherRemoteMetadata(file: OCFile, fileSize: String, timestamp: Long) = ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + location = file.geoLocation?.let { Pair(it.latitude, it.longitude) }, + width = file.imageDimension?.width?.toInt(), + length = file.imageDimension?.height?.toInt() + ) + + private fun parseTimestamp(exif: ExifInterface, fallback: Long): Long = + exif.getAttribute(ExifInterface.TAG_DATETIME)?.let { + SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time + } ?: fallback + + @Suppress("ReturnCount") + private fun parseExposure(exif: ExifInterface): String? { + val shutterSpeed = exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE) + val exposureTime = exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME) + ?.let { "1/${(1 / it.toDouble()).toInt()}" } + + val raw = shutterSpeed ?: exposureTime ?: return null + + if ('/' !in raw) return raw + + return runCatching { + raw.split("/").let { parts -> + "1/${2f.pow(parts[0].toFloat() / parts[1].toFloat()).roundToInt()}" + } + }.getOrDefault(raw) + } + + private fun parseImageDimensions(exif: ExifInterface, storagePath: String): Pair { + val width = exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.toInt() + val length = exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.toInt() + + if ((width ?: 0) > 0 && (length ?: 0) > 0) return Pair(width, length) + + return BitmapUtils.getImageResolution(storagePath).let { Pair(it[0], it[1]) } + } + + private fun formatDate(timestamp: Long): String = buildString { + append(SimpleDateFormat("EEEE", Locale.getDefault()).format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) + } + + private fun imagePinDrawable(context: Context, file: OCFile): LayerDrawable = + (ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable).apply { + val bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId + ) + BitmapUtils.bitmapToCircularBitmapDrawable(fragment.resources, bitmap)?.let { + setDrawable(1, it) + } + } + + private fun markerOnGestureListener(latitude: Double, longitude: Double) = + object : ItemizedIconOverlay.OnItemGestureListener { + override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { + val intent = Intent(Intent.ACTION_VIEW, "geo:0,0?q=$latitude,$longitude".toUri()) + DisplayUtils.startIntentIfAppAvailable(intent, fragment.activity, R.string.no_map_app_availble) + return true + } + + override fun onItemLongPress(index: Int, item: OverlayItem) = false + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt new file mode 100644 index 000000000000..10c65aeaaf3a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo.model + +data class GovernanceLabel(val id: String, val text: String, val color: String) { + override fun toString(): String = text +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt new file mode 100644 index 000000000000..ad57a9141f40 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ImageMetadata( + val fileSize: String? = null, + val date: String? = null, + val length: Int? = null, + val width: Int? = null, + val exposure: String? = null, + val aperture: String? = null, + val focalLen: String? = null, + val iso: String? = null, + val make: String? = null, + val model: String? = null, + val location: Pair? = null +) : Parcelable diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt index 06e4ff1a9b11..6039821d2276 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt @@ -21,9 +21,9 @@ fun OwnCloudClient.toNextcloudClient(context: Context): NextcloudClient = OwnClo isFollowRedirects ) -fun OwnCloudClient.getPreviewEndpoint(remoteId: String, x: Int, y: Int): String = baseUri +fun OwnCloudClient.getPreviewEndpoint(fileId: Long, x: Int, y: Int): String = baseUri .toString() + "/index.php/core/preview?fileId=" + - remoteId + + fileId + "&x=" + (x / 2) + "&y=" + (y / 2) + "&a=1&mode=cover&forceIcon=0" 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 a82ad391bb5d..1e2cfd25c8ed 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2395,6 +2395,7 @@ private ContentValues createContentValues(String accountName, OCCapability capab contentValues.put(ProviderTableMeta.CAPABILITIES_GROUPFOLDERS, capability.getGroupfolders().getValue()); contentValues.put(ProviderTableMeta.CAPABILITIES_DROP_ACCOUNT, capability.getDropAccount().getValue()); contentValues.put(ProviderTableMeta.CAPABILITIES_SECURITY_GUARD, capability.getSecurityGuard().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_GOVERNANCE, capability.getGovernance().getValue()); contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS, capability.getForbiddenFilenameCharactersJson()); contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES, capability.getForbiddenFilenamesJson()); @@ -2584,6 +2585,7 @@ private OCCapability createCapabilityInstance(Cursor cursor) { capability.setGroupfolders(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_GROUPFOLDERS)); capability.setDropAccount(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_DROP_ACCOUNT)); capability.setSecurityGuard(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SECURITY_GUARD)); + capability.setGovernance(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_GOVERNANCE)); capability.setForbiddenFilenameCharactersJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS)); capability.setForbiddenFilenamesJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES)); diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index 3586f6adeba5..a71a1d77b5f0 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -1286,7 +1286,7 @@ public static Bitmap doResizedImageInBackground(OCFile file, FileDataStorageMana GetMethod getMethod = null; try { - String uri = OwnCloudClientExtensionsKt.getPreviewEndpoint(mClient, file.getRemoteId(), pxW, pxH); + String uri = OwnCloudClientExtensionsKt.getPreviewEndpoint(mClient, file.getLocalId(), pxW, pxH); Log_OC.d(TAG, "generating resized image: " + file.getFileName() + " URI: " + uri); getMethod = new GetMethod(uri); 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 e12b0b44ec54..4e497e32eafa 100644 --- a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java +++ b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -23,7 +23,7 @@ */ public class ProviderMeta { public static final String DB_NAME = "filelist"; - public static final int DB_VERSION = 100; + public static final int DB_VERSION = 101; private ProviderMeta() { // No instance @@ -282,6 +282,7 @@ static public class ProviderTableMeta implements BaseColumns { public static final String CAPABILITIES_GROUPFOLDERS = "groupfolders"; public static final String CAPABILITIES_DROP_ACCOUNT = "drop_account"; public static final String CAPABILITIES_SECURITY_GUARD = "security_guard"; + public static final String CAPABILITIES_GOVERNANCE = "governance"; public static final String CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS = "forbidden_filename_characters"; public static final String CAPABILITIES_FORBIDDEN_FILENAMES = "forbidden_filenames"; public static final String CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS = "forbidden_filename_extensions"; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java deleted file mode 100644 index 9f0f271e494c..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2018 Andy Scherzinger - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.ui.adapter; - -import com.nextcloud.client.account.User; -import com.nextcloud.ui.ImageDetailFragment; -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment; -import com.owncloud.android.ui.fragment.FileDetailSharingFragment; -import com.owncloud.android.utils.MimeTypeUtil; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -/** - * File details pager adapter. - */ -public class FileDetailTabAdapter extends FragmentStateAdapter { - private final OCFile file; - private final User user; - private final boolean showSharingTab; - - private FileDetailSharingFragment fileDetailSharingFragment; - private FileDetailActivitiesFragment fileDetailActivitiesFragment; - private ImageDetailFragment imageDetailFragment; - - public FileDetailTabAdapter(FragmentActivity fragmentActivity, - OCFile file, - User user, - boolean showSharingTab) { - super(fragmentActivity); - this.file = file; - this.user = user; - this.showSharingTab = showSharingTab; - } - - public FileDetailSharingFragment getFileDetailSharingFragment() { - return fileDetailSharingFragment; - } - - public FileDetailActivitiesFragment getFileDetailActivitiesFragment() { - return fileDetailActivitiesFragment; - } - - public ImageDetailFragment getImageDetailFragment() { - return imageDetailFragment; - } - - @NonNull - @Override - public Fragment createFragment(int position) { - return switch (position) { - case 1 -> { - fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user); - yield fileDetailSharingFragment; - } - case 2 -> { - imageDetailFragment = ImageDetailFragment.newInstance(file, user); - yield imageDetailFragment; - } - default -> { - fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user); - yield fileDetailActivitiesFragment; - } - }; - } - - @Override - public int getItemCount() { - if (showSharingTab) { - if (MimeTypeUtil.isImage(file)) { - return 3; - } - return 2; - } else { - if (MimeTypeUtil.isImage(file)) { - return 2; - } - return 1; - } - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt new file mode 100644 index 000000000000..7633297ce066 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt @@ -0,0 +1,62 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.nextcloud.client.account.User +import com.nextcloud.ui.fileInfo.FileInfoFragment +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment +import com.owncloud.android.ui.fragment.FileDetailSharingFragment + +class FileDetailTabAdapter( + private val fragmentActivity: FragmentActivity, + private val file: OCFile, + private val user: User, + private val showSharingTab: Boolean, + private val showDetailsTab: Boolean +) : FragmentStateAdapter(fragmentActivity) { + + private enum class Tab(val position: Int) { + Activities(0), + Sharing(1), + Details(2) + } + + var fileDetailSharingFragment: FileDetailSharingFragment? = null + private set + var fileDetailActivitiesFragment: FileDetailActivitiesFragment? = null + private set + + override fun createFragment(position: Int): Fragment = when (position) { + Tab.Sharing.position -> FileDetailSharingFragment.newInstance(file, user) + .also { fileDetailSharingFragment = it } + + Tab.Details.position -> FileInfoFragment.newInstance(file, user) + + else -> FileDetailActivitiesFragment.newInstance(file, user) + .also { fileDetailActivitiesFragment = it } + } + + override fun getItemCount(): Int { + // always show Activities + var count = 1 + + if (showSharingTab) { + count++ + } + + if (showDetailsTab) { + count++ + } + + return count + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java index 829e1b4dcc31..3a7e4dd66566 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java @@ -36,7 +36,6 @@ import com.nextcloud.ui.fileactions.FileActionsBottomSheet; import com.nextcloud.utils.MenuUtils; import com.nextcloud.utils.extensions.BundleExtensionsKt; -import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.mdm.MDMConfig; import com.owncloud.android.MainApp; import com.owncloud.android.R; @@ -65,6 +64,7 @@ import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.theme.CapabilityUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.EventBus; @@ -316,8 +316,8 @@ private void setupViewPager() { binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.selector_tab_share)); } - if (MimeTypeUtil.isImage(getFile())) { - binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_media)); + if (showDetailsTab()) { + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_file_info)); } viewThemeUtils.material.themeTabLayout(binding.tabLayout); @@ -325,7 +325,8 @@ private void setupViewPager() { final FileDetailTabAdapter adapter = new FileDetailTabAdapter(requireActivity(), getFile(), user, - showSharingTab()); + showSharingTab(), + showDetailsTab()); binding.pager.setAdapter(adapter); binding.pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @@ -385,7 +386,6 @@ public void onTabReselected(TabLayout.Tab tab) { @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - FileExtensionsKt.logFileSize(getFile(), TAG); outState.putParcelable(ARG_FILE, getFile()); outState.putParcelable(ARG_USER, user); } @@ -901,4 +901,9 @@ private boolean showSharingTab() { return true; } } + + private boolean showDetailsTab() { + return CapabilityUtils.getCapability(getContext()).getGovernance().isTrue() || + MimeTypeUtil.isMedia(getFile().getMimeType()); + } } diff --git a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt index c52e6c509773..ccf8152a8f8d 100644 --- a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt @@ -28,6 +28,7 @@ import androidx.appcompat.widget.SearchView import androidx.core.content.res.ResourcesCompat import com.google.android.material.card.MaterialCardView import com.google.android.material.navigation.NavigationView +import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.nextcloud.android.common.ui.color.ColorUtil import com.nextcloud.android.common.ui.theme.MaterialSchemes import com.nextcloud.android.common.ui.theme.ViewThemeUtilsBase @@ -312,6 +313,16 @@ class FilesSpecificViewThemeUtils @Inject constructor( } } + fun themeAutoCompleteTextView(autoCompleteTextView: MaterialAutoCompleteTextView) { + withScheme(autoCompleteTextView) { scheme -> + autoCompleteTextView.setTextColor(dynamicColor.onSurface().getArgb(scheme)) + autoCompleteTextView.setHintTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) + autoCompleteTextView.setDropDownBackgroundTintList( + ColorStateList.valueOf(dynamicColor.surfaceContainer().getArgb(scheme)) + ) + } + } + companion object { private val TAG = FilesSpecificViewThemeUtils::class.simpleName diff --git a/app/src/main/res/drawable/ic_dashboard_filled.xml b/app/src/main/res/drawable/ic_dashboard_filled.xml new file mode 100644 index 000000000000..111b1e97f44a --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_filled.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_dashboard_outlined.xml b/app/src/main/res/drawable/ic_dashboard_outlined.xml new file mode 100644 index 000000000000..9d32ead03ef7 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_outlined.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/selector_file_info.xml b/app/src/main/res/drawable/selector_file_info.xml new file mode 100644 index 000000000000..71684759b904 --- /dev/null +++ b/app/src/main/res/drawable/selector_file_info.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml new file mode 100644 index 000000000000..10603b333e73 --- /dev/null +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_dropdown_with_icon.xml b/app/src/main/res/layout/item_dropdown_with_icon.xml new file mode 100644 index 000000000000..ac31e3f898fd --- /dev/null +++ b/app/src/main/res/layout/item_dropdown_with_icon.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/layout/preview_image_details_fragment.xml b/app/src/main/res/layout/preview_image_details_fragment.xml deleted file mode 100644 index 72f0017e2ac5..000000000000 --- a/app/src/main/res/layout/preview_image_details_fragment.xml +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 2db10bcc81a9..caa661d4fda6 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -31,6 +31,7 @@ 8dp 4dp 32dp + 12dp 2dp 16dp 32dp @@ -38,7 +39,7 @@ 100dp 100dp 4dp - + 300dp 14dp 12sp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d662ed36f1bf..f4c609f0b9a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1526,4 +1526,8 @@ Upload conflicts detected. Open uploads to resolve. Resolve conflicts Extension cannot be changed + Sensitivity label + File retention + Governance + Image details diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 9b38429f9e72..3290234bce51 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -197,6 +197,7 @@ + @@ -257,6 +258,7 @@ + @@ -521,12 +523,17 @@ + - + + + + + @@ -20059,6 +20066,11 @@ + + + + + @@ -28005,6 +28017,9 @@ + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 651d7a880838..7a4fca194396 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,20 +43,19 @@ dependencyResolutionManagement { /* Needed for local android library - -includeBuild("../android_library") { +*/ +includeBuild("../android-library") { dependencySubstitution { substitute(module("com.github.nextcloud:android-library")) .using(project(":library")) } } -*/ /* Needed for local android common library -includeBuild("../android_common") { +includeBuild("../android-common") { dependencySubstitution { substitute(module("com.github.nextcloud.android-common:core")) .using(project(":core"))