瀏覽代碼

- overhaul metadata capture and processing
- exception handling in NominatimClient
- set map boundaries
- format strings according to locale
- overlay drawables for photo pin correctly
- refactored ImageDetailFragment
- add copyright notices
- add support for metadata from server
- tint icons for dark mode

Signed-off-by: tobiasKaminsky <tobias@kaminsky.me>
Signed-off-by: ZetaTom <70907959+ZetaTom@users.noreply.github.com>

ZetaTom 1 年之前
父節點
當前提交
f57be44343
共有 31 個文件被更改,包括 2095 次插入93 次删除
  1. 2 0
      app/build.gradle
  2. 1149 0
      app/schemas/com.nextcloud.client.database.NextcloudDatabase/72.json
  3. 二進制
      app/screenshots/gplay/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_open.png
  4. 二進制
      app/screenshots/gplay/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_openMultiAccount.png
  5. 二進制
      app/screenshots/gplay/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailDetailsFragment.png
  6. 二進制
      app/src/androidTest/assets/gps.jpg
  7. 56 1
      app/src/androidTest/java/com/owncloud/android/UploadIT.java
  8. 23 7
      app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt
  9. 1 1
      app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt
  10. 96 0
      app/src/main/java/com/nextcloud/client/NominatimClient.kt
  11. 2 1
      app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt
  12. 3 1
      app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt
  13. 1 0
      app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigration.kt
  14. 3 0
      app/src/main/java/com/nextcloud/client/di/ComponentsModule.java
  15. 411 0
      app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt
  16. 14 2
      app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java
  17. 0 24
      app/src/main/java/com/owncloud/android/datamodel/ImageDimension.kt
  18. 15 2
      app/src/main/java/com/owncloud/android/datamodel/OCFile.java
  19. 1 0
      app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java
  20. 49 47
      app/src/main/java/com/owncloud/android/db/ProviderMeta.java
  21. 17 6
      app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java
  22. 1 1
      app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt
  23. 9 0
      app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java
  24. 9 0
      app/src/main/java/com/owncloud/android/utils/BitmapUtils.java
  25. 2 0
      app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java
  26. 5 0
      app/src/main/res/drawable/outline_camera_24.xml
  27. 5 0
      app/src/main/res/drawable/outline_image_24.xml
  28. 36 0
      app/src/main/res/drawable/photo_pin.xml
  29. 171 0
      app/src/main/res/layout/preview_image_details_fragment.xml
  30. 5 0
      app/src/main/res/values/setup.xml
  31. 9 0
      app/src/main/res/values/strings.xml

+ 2 - 0
app/build.gradle

@@ -318,6 +318,8 @@ dependencies {
     // dependencies for image cropping and rotation
     implementation 'com.vanniktech:android-image-cropper:4.5.0'
 
+    implementation 'org.osmdroid:osmdroid-android:6.1.16'
+
     implementation('org.mnode.ical4j:ical4j:3.0.0') {
         ['org.apache.commons', 'commons-logging'].each {
             exclude group: "$it"

+ 1149 - 0
app/schemas/com.nextcloud.client.database.NextcloudDatabase/72.json

@@ -0,0 +1,1149 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 72,
+    "identityHash": "588228aa504f37ac818ca4a664af5b3d",
+    "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",
+            "notNull": false
+          },
+          {
+            "fieldPath": "cloudId",
+            "columnName": "cloud_id",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "capabilities",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_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_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, `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)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "versionMajor",
+            "columnName": "version_mayor",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "versionMinor",
+            "columnName": "version_minor",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "versionMicro",
+            "columnName": "version_micro",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "versionString",
+            "columnName": "version_string",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "versionEditor",
+            "columnName": "version_edition",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "extendedSupport",
+            "columnName": "extended_support",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "corePollinterval",
+            "columnName": "core_pollinterval",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingApiEnabled",
+            "columnName": "sharing_api_enabled",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicEnabled",
+            "columnName": "sharing_public_enabled",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicPasswordEnforced",
+            "columnName": "sharing_public_password_enforced",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicExpireDateEnabled",
+            "columnName": "sharing_public_expire_date_enabled",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicExpireDateDays",
+            "columnName": "sharing_public_expire_date_days",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicExpireDateEnforced",
+            "columnName": "sharing_public_expire_date_enforced",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicSendMail",
+            "columnName": "sharing_public_send_mail",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicUpload",
+            "columnName": "sharing_public_upload",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingUserSendMail",
+            "columnName": "sharing_user_send_mail",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingResharing",
+            "columnName": "sharing_resharing",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingFederationOutgoing",
+            "columnName": "sharing_federation_outgoing",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingFederationIncoming",
+            "columnName": "sharing_federation_incoming",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "filesBigfilechunking",
+            "columnName": "files_bigfilechunking",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "filesUndelete",
+            "columnName": "files_undelete",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "filesVersioning",
+            "columnName": "files_versioning",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "externalLinks",
+            "columnName": "external_links",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverName",
+            "columnName": "server_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverColor",
+            "columnName": "server_color",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverTextColor",
+            "columnName": "server_text_color",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverElementColor",
+            "columnName": "server_element_color",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverSlogan",
+            "columnName": "server_slogan",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverLogo",
+            "columnName": "server_logo",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverBackgroundUrl",
+            "columnName": "background_url",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "endToEndEncryption",
+            "columnName": "end_to_end_encryption",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "endToEndEncryptionKeysExist",
+            "columnName": "end_to_end_encryption_keys_exist",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "activity",
+            "columnName": "activity",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverBackgroundDefault",
+            "columnName": "background_default",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "serverBackgroundPlain",
+            "columnName": "background_plain",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "richdocument",
+            "columnName": "richdocument",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "richdocumentMimetypeList",
+            "columnName": "richdocument_mimetype_list",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "richdocumentDirectEditing",
+            "columnName": "richdocument_direct_editing",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "richdocumentTemplates",
+            "columnName": "richdocument_direct_templates",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "richdocumentOptionalMimetypeList",
+            "columnName": "richdocument_optional_mimetype_list",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharingPublicAskForOptionalPassword",
+            "columnName": "sharing_public_ask_for_optional_password",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "richdocumentProductName",
+            "columnName": "richdocument_product_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "directEditingEtag",
+            "columnName": "direct_editing_etag",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "userStatus",
+            "columnName": "user_status",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "userStatusSupportsEmoji",
+            "columnName": "user_status_supports_emoji",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "etag",
+            "columnName": "etag",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "filesLockingVersion",
+            "columnName": "files_locking_version",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "groupfolders",
+            "columnName": "groupfolders",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "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",
+            "notNull": false
+          },
+          {
+            "fieldPath": "iconUrl",
+            "columnName": "icon_url",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "language",
+            "columnName": "language",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "redirect",
+            "columnName": "redirect",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "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, `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, `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)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "filename",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "encryptedName",
+            "columnName": "encrypted_filename",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "path",
+            "columnName": "path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "pathDecrypted",
+            "columnName": "path_decrypted",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "parent",
+            "columnName": "parent",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "creation",
+            "columnName": "created",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "modified",
+            "columnName": "modified",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "content_type",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "contentLength",
+            "columnName": "content_length",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "storagePath",
+            "columnName": "media_path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountOwner",
+            "columnName": "file_owner",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastSyncDate",
+            "columnName": "last_sync_date",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastSyncDateForData",
+            "columnName": "last_sync_date_for_data",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "modifiedAtLastSyncForData",
+            "columnName": "modified_at_last_sync_for_data",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "etag",
+            "columnName": "etag",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "etagOnServer",
+            "columnName": "etag_on_server",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharedViaLink",
+            "columnName": "share_by_link",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "permissions",
+            "columnName": "permissions",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "remoteId",
+            "columnName": "remote_id",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "localId",
+            "columnName": "local_id",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "updateThumbnail",
+            "columnName": "update_thumbnail",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isDownloading",
+            "columnName": "is_downloading",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "favorite",
+            "columnName": "favorite",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isEncrypted",
+            "columnName": "is_encrypted",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "etagInConflict",
+            "columnName": "etag_in_conflict",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharedWithSharee",
+            "columnName": "shared_via_users",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "mountType",
+            "columnName": "mount_type",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "hasPreview",
+            "columnName": "has_preview",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "unreadCommentsCount",
+            "columnName": "unread_comments_count",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "ownerId",
+            "columnName": "owner_id",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "ownerDisplayName",
+            "columnName": "owner_display_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "note",
+            "columnName": "note",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharees",
+            "columnName": "sharees",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "richWorkspace",
+            "columnName": "rich_workspace",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "metadataSize",
+            "columnName": "metadata_size",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "locked",
+            "columnName": "locked",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lockType",
+            "columnName": "lock_type",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lockOwner",
+            "columnName": "lock_owner",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lockOwnerDisplayName",
+            "columnName": "lock_owner_display_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lockOwnerEditor",
+            "columnName": "lock_owner_editor",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lockTimestamp",
+            "columnName": "lock_timestamp",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lockTimeout",
+            "columnName": "lock_timeout",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lockToken",
+            "columnName": "lock_token",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "tags",
+            "columnName": "tags",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "metadataGPS",
+            "columnName": "metadata_gps",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "filesystem",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_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",
+            "notNull": false
+          },
+          {
+            "fieldPath": "localPath",
+            "columnName": "local_path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "fileIsFolder",
+            "columnName": "is_folder",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "fileFoundRecently",
+            "columnName": "found_at",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "fileSentForUpload",
+            "columnName": "upload_triggered",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "syncedFolderId",
+            "columnName": "syncedfolder_id",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "crc32",
+            "columnName": "crc32",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "fileModified",
+            "columnName": "modified_at",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "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` INTEGER, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "fileSource",
+            "columnName": "file_source",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "itemSource",
+            "columnName": "item_source",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "shareType",
+            "columnName": "share_type",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "shareWith",
+            "columnName": "shate_with",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "path",
+            "columnName": "path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "permissions",
+            "columnName": "permissions",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "sharedDate",
+            "columnName": "shared_date",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "expirationDate",
+            "columnName": "expiration_date",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "token",
+            "columnName": "token",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "shareWithDisplayName",
+            "columnName": "shared_with_display_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isDirectory",
+            "columnName": "is_directory",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "userId",
+            "columnName": "user_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "idRemoteShared",
+            "columnName": "id_remote_shared",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountOwner",
+            "columnName": "owner_share",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isPasswordProtected",
+            "columnName": "is_password_protected",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "note",
+            "columnName": "note",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "hideDownload",
+            "columnName": "hide_download",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "shareLink",
+            "columnName": "share_link",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "shareLabel",
+            "columnName": "share_label",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "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)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "localPath",
+            "columnName": "local_path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "remotePath",
+            "columnName": "remote_path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "wifiOnly",
+            "columnName": "wifi_only",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "chargingOnly",
+            "columnName": "charging_only",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "existing",
+            "columnName": "existing",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "enabledTimestampMs",
+            "columnName": "enabled_timestamp_ms",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "subfolderByDate",
+            "columnName": "subfolder_by_date",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "account",
+            "columnName": "account",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "uploadAction",
+            "columnName": "upload_option",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "nameCollisionPolicy",
+            "columnName": "name_collision_policy",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "hidden",
+            "columnName": "hidden",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "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, `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",
+            "notNull": false
+          },
+          {
+            "fieldPath": "localPath",
+            "columnName": "local_path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "remotePath",
+            "columnName": "remote_path",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "accountName",
+            "columnName": "account_name",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "fileSize",
+            "columnName": "file_size",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "localBehaviour",
+            "columnName": "local_behaviour",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "uploadTime",
+            "columnName": "upload_time",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "nameCollisionPolicy",
+            "columnName": "name_collision_policy",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isCreateRemoteFolder",
+            "columnName": "is_create_remote_folder",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "uploadEndTimestamp",
+            "columnName": "upload_end_timestamp",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "lastResult",
+            "columnName": "last_result",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isWhileChargingOnly",
+            "columnName": "is_while_charging_only",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isWifiOnly",
+            "columnName": "is_wifi_only",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "createdBy",
+            "columnName": "created_by",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "folderUnlockToken",
+            "columnName": "folder_unlock_token",
+            "affinity": "TEXT",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "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",
+            "notNull": false
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "ocFileId",
+            "columnName": "ocfile_id",
+            "affinity": "INTEGER",
+            "notNull": false
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "_id"
+          ]
+        },
+        "indices": [],
+        "foreignKeys": []
+      }
+    ],
+    "views": [],
+    "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, '588228aa504f37ac818ca4a664af5b3d')"
+    ]
+  }
+}

二進制
app/screenshots/gplay/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_open.png


二進制
app/screenshots/gplay/debug/com.owncloud.android.ui.activity.ReceiveExternalFilesActivityIT_openMultiAccount.png


二進制
app/screenshots/gplay/debug/com.owncloud.android.ui.fragment.FileDetailFragmentStaticServerIT_showFileDetailDetailsFragment.png


二進制
app/src/androidTest/assets/gps.jpg


+ 56 - 1
app/src/androidTest/java/com/owncloud/android/UploadIT.java

@@ -32,6 +32,8 @@ import com.owncloud.android.db.OCUpload;
 import com.owncloud.android.files.services.FileUploader;
 import com.owncloud.android.files.services.NameCollisionPolicy;
 import com.owncloud.android.lib.common.operations.RemoteOperationResult;
+import com.owncloud.android.lib.resources.files.model.GeoLocation;
+import com.owncloud.android.lib.resources.files.model.ImageDimension;
 import com.owncloud.android.operations.RefreshFolderOperation;
 import com.owncloud.android.operations.RemoveFileOperation;
 import com.owncloud.android.operations.UploadFileOperation;
@@ -52,6 +54,7 @@ import androidx.annotation.NonNull;
 
 import static junit.framework.TestCase.assertEquals;
 import static junit.framework.TestCase.assertFalse;
+import static junit.framework.TestCase.assertNotNull;
 import static junit.framework.TestCase.assertTrue;
 
 /**
@@ -469,7 +472,59 @@ public class UploadIT extends AbstractOnServerIT {
         assertEquals(remotePath, ocFile.getRemotePath());
         assertEquals(creationTimestamp, ocFile.getCreationTimestamp());
         assertTrue(uploadTimestamp - 10 < ocFile.getUploadTimestamp() ||
-                       uploadTimestamp + 10 > ocFile.getUploadTimestamp());
+                           uploadTimestamp + 10 > ocFile.getUploadTimestamp());
+    }
+
+    @Test
+    public void testMetadata() throws IOException {
+        File file = getFile("gps.jpg");
+        String remotePath = "/gps.jpg";
+        OCUpload ocUpload = new OCUpload(file.getAbsolutePath(), remotePath, account.name);
+
+        assertTrue(
+                new UploadFileOperation(
+                        uploadsStorageManager,
+                        connectivityServiceMock,
+                        powerManagementServiceMock,
+                        user,
+                        null,
+                        ocUpload,
+                        NameCollisionPolicy.DEFAULT,
+                        FileUploader.LOCAL_BEHAVIOUR_COPY,
+                        targetContext,
+                        false,
+                        false,
+                        getStorageManager()
+                )
+                        .setRemoteFolderToBeCreated()
+                        .execute(client)
+                        .isSuccess()
+                  );
+
+        // RefreshFolderOperation
+        assertTrue(new RefreshFolderOperation(getStorageManager().getFileByDecryptedRemotePath("/"),
+                                              System.currentTimeMillis() / 1000,
+                                              false,
+                                              false,
+                                              getStorageManager(),
+                                              user,
+                                              targetContext).execute(client).isSuccess());
+
+        List<OCFile> files = getStorageManager().getFolderContent(getStorageManager().getFileByDecryptedRemotePath("/"),
+                                                                  false);
+
+        OCFile ocFile = null;
+        for (OCFile f : files) {
+            if (f.getFileName().equals("gps.jpg")) {
+                ocFile = f;
+                break;
+            }
+        }
+
+        assertNotNull(ocFile);
+        assertEquals(remotePath, ocFile.getRemotePath());
+        assertEquals(new ImageDimension(451f, 529f), ocFile.getImageDimension());
+        assertEquals(new GeoLocation(49.99679166666667, 8.67198611111111), ocFile.getGeoLocation());
     }
 
     private void verifyStoragePath(OCFile file) {

+ 23 - 7
app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt

@@ -24,6 +24,7 @@ package com.owncloud.android.ui.fragment
 
 import androidx.test.espresso.intent.rule.IntentsTestRule
 import com.nextcloud.test.TestActivity
+import com.nextcloud.ui.ImageDetailFragment
 import com.owncloud.android.AbstractIT
 import com.owncloud.android.R
 import com.owncloud.android.datamodel.OCFile
@@ -40,13 +41,16 @@ class FileDetailFragmentStaticServerIT : AbstractIT() {
     @get:Rule
     val testActivityRule = IntentsTestRule(TestActivity::class.java, true, false)
 
-    val file = OCFile("/")
+    var file = getFile("gps.jpg")
+    val oCFile = OCFile("/").apply {
+        storagePath = file.absolutePath
+    }
 
     @Test
     @ScreenshotTest
     fun showFileDetailActivitiesFragment() {
         val sut = testActivityRule.launchActivity(null)
-        sut.addFragment(FileDetailActivitiesFragment.newInstance(file, user))
+        sut.addFragment(FileDetailActivitiesFragment.newInstance(oCFile, user))
 
         waitForIdleSync()
         shortSleep()
@@ -58,7 +62,19 @@ class FileDetailFragmentStaticServerIT : AbstractIT() {
     @ScreenshotTest
     fun showFileDetailSharingFragment() {
         val sut = testActivityRule.launchActivity(null)
-        sut.addFragment(FileDetailSharingFragment.newInstance(file, user))
+        sut.addFragment(FileDetailSharingFragment.newInstance(oCFile, user))
+
+        waitForIdleSync()
+        shortSleep()
+        shortSleep()
+        screenshot(sut)
+    }
+
+    @Test
+    @ScreenshotTest
+    fun showFileDetailDetailsFragment() {
+        val sut = testActivityRule.launchActivity(null)
+        sut.addFragment(ImageDetailFragment.newInstance(oCFile, user))
 
         waitForIdleSync()
         shortSleep()
@@ -71,7 +87,7 @@ class FileDetailFragmentStaticServerIT : AbstractIT() {
     @Suppress("MagicNumber")
     fun showDetailsActivities() {
         val activity = testActivityRule.launchActivity(null)
-        val sut = FileDetailFragment.newInstance(file, user, 0)
+        val sut = FileDetailFragment.newInstance(oCFile, user, 0)
         activity.addFragment(sut)
 
         waitForIdleSync()
@@ -141,7 +157,7 @@ class FileDetailFragmentStaticServerIT : AbstractIT() {
     // @ScreenshotTest
     fun showDetailsActivitiesNone() {
         val activity = testActivityRule.launchActivity(null)
-        val sut = FileDetailFragment.newInstance(file, user, 0)
+        val sut = FileDetailFragment.newInstance(oCFile, user, 0)
         activity.addFragment(sut)
 
         waitForIdleSync()
@@ -159,7 +175,7 @@ class FileDetailFragmentStaticServerIT : AbstractIT() {
     @ScreenshotTest
     fun showDetailsActivitiesError() {
         val activity = testActivityRule.launchActivity(null)
-        val sut = FileDetailFragment.newInstance(file, user, 0)
+        val sut = FileDetailFragment.newInstance(oCFile, user, 0)
         activity.addFragment(sut)
 
         waitForIdleSync()
@@ -179,7 +195,7 @@ class FileDetailFragmentStaticServerIT : AbstractIT() {
     @ScreenshotTest
     fun showDetailsSharing() {
         val sut = testActivityRule.launchActivity(null)
-        sut.addFragment(FileDetailFragment.newInstance(file, user, 1))
+        sut.addFragment(FileDetailFragment.newInstance(oCFile, user, 1))
 
         waitForIdleSync()
 

+ 1 - 1
app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt

@@ -29,12 +29,12 @@ import android.graphics.Paint
 import androidx.test.espresso.intent.rule.IntentsTestRule
 import com.nextcloud.test.TestActivity
 import com.owncloud.android.AbstractIT
-import com.owncloud.android.datamodel.ImageDimension
 import com.owncloud.android.datamodel.OCFile
 import com.owncloud.android.datamodel.ThumbnailsCacheManager
 import com.owncloud.android.datamodel.ThumbnailsCacheManager.InitDiskCacheTask
 import com.owncloud.android.datamodel.ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE
 import com.owncloud.android.lib.common.utils.Log_OC
+import com.owncloud.android.lib.resources.files.model.ImageDimension
 import com.owncloud.android.utils.ScreenshotTest
 import org.junit.After
 import org.junit.Assert.assertNotNull

+ 96 - 0
app/src/main/java/com/nextcloud/client/NominatimClient.kt

@@ -0,0 +1,96 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author ZetaTom
+ * Copyright (C) 2023 ZetaTom
+ * Copyright (C) 2023 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.client
+
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import com.owncloud.android.MainApp
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.internal.http.HTTP_OK
+import java.net.URLEncoder
+
+class NominatimClient constructor(geocoderBaseUrl: String, email: String) {
+    private val client = OkHttpClient()
+    private val gson = Gson()
+    private val reverseUrl = "${geocoderBaseUrl}reverse?format=jsonv2&email=${URLEncoder.encode(email, ENCODING_UTF_8)}"
+
+    private fun doRequest(requestUrl: String): String? {
+        val request = Request.Builder().url(requestUrl).header(HEADER_USER_AGENT, MainApp.getUserAgent()).build()
+
+        try {
+            val response = client.newCall(request).execute()
+            if (response.code == HTTP_OK) {
+                return response.body.string()
+            }
+        } catch (_: Exception) {
+        }
+
+        return null
+    }
+
+    /**
+     * Reverse geocode specified location - get human readable name suitable for displaying from given coordinates.
+     *
+     * @param latitude GPS latitude
+     * @param longitude GPS longitude
+     * @param zoom level of detail to request
+     */
+    fun reverseGeocode(
+        latitude: Double,
+        longitude: Double,
+        zoom: ZoomLevel = ZoomLevel.TOWN_BOROUGH
+    ): ReverseGeocodingResult? {
+        val response = doRequest("$reverseUrl&addressdetails=0&zoom=${zoom.int}&lat=$latitude&lon=$longitude")
+        return response?.let { gson.fromJson(it, ReverseGeocodingResult::class.java) }
+    }
+
+    companion object {
+        private const val ENCODING_UTF_8 = "UTF-8"
+        private const val HEADER_USER_AGENT = "User-Agent"
+
+        @Suppress("MagicNumber")
+        enum class ZoomLevel(val int: Int) {
+            COUNTRY(3),
+            STATE(5),
+            COUNTY(8),
+            CITY(10),
+            TOWN_BOROUGH(12),
+            VILLAGE_SUBURB(13),
+            NEIGHBOURHOOD(14),
+            LOCALITY(15),
+            MAJOR_STREETS(16),
+            MINOR_STREETS(17),
+            BUILDING(18),
+            MAX(19)
+        }
+
+        data class ReverseGeocodingResult(
+            @SerializedName("lat")
+            val latitude: Double,
+            @SerializedName("lon")
+            val longitude: Double,
+            val name: String,
+            @SerializedName("display_name")
+            val displayName: String
+        )
+    }
+}

+ 2 - 1
app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt

@@ -63,7 +63,8 @@ import com.owncloud.android.db.ProviderMeta
         AutoMigration(from = 65, to = 66),
         AutoMigration(from = 66, to = 67),
         AutoMigration(from = 68, to = 69),
-        AutoMigration(from = 69, to = 70)
+        AutoMigration(from = 69, to = 70),
+        AutoMigration(from = 71, to = 72)
     ],
     exportSchema = true
 )

+ 3 - 1
app/src/main/java/com/nextcloud/client/database/entity/FileEntity.kt

@@ -119,5 +119,7 @@ data class FileEntity(
     @ColumnInfo(name = ProviderTableMeta.FILE_LOCK_TOKEN)
     val lockToken: String?,
     @ColumnInfo(name = ProviderTableMeta.FILE_TAGS)
-    val tags: String?
+    val tags: String?,
+    @ColumnInfo(name = ProviderTableMeta.FILE_METADATA_GPS)
+    val metadataGPS: String?
 )

+ 1 - 0
app/src/main/java/com/nextcloud/client/database/migrations/LegacyMigration.kt

@@ -50,6 +50,7 @@ class LegacyMigration(
  *
  * This is needed because the [Migration] does not know which versions it's dealing with
  */
+@Suppress("ForEachOnRange")
 fun RoomDatabase.Builder<NextcloudDatabase>.addLegacyMigrations(
     clock: Clock
 ): RoomDatabase.Builder<NextcloudDatabase> {

+ 3 - 0
app/src/main/java/com/nextcloud/client/di/ComponentsModule.java

@@ -35,6 +35,7 @@ import com.nextcloud.client.widget.DashboardWidgetConfigurationActivity;
 import com.nextcloud.client.widget.DashboardWidgetProvider;
 import com.nextcloud.client.widget.DashboardWidgetService;
 import com.nextcloud.ui.ChooseAccountDialogFragment;
+import com.nextcloud.ui.ImageDetailFragment;
 import com.nextcloud.ui.SetStatusDialogFragment;
 import com.nextcloud.ui.fileactions.FileActionsBottomSheet;
 import com.nmc.android.ui.LauncherActivity;
@@ -475,4 +476,6 @@ abstract class ComponentsModule {
     @ContributesAndroidInjector
     abstract EditImageActivity editImageActivity();
 
+    @ContributesAndroidInjector
+    abstract ImageDetailFragment imageDetailFragment();
 }

+ 411 - 0
app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt

@@ -0,0 +1,411 @@
+/*
+ * Nextcloud Android client application
+ *
+ * @author ZetaTom
+ * Copyright (C) 2023 ZetaTom
+ * Copyright (C) 2023 Nextcloud GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.ui
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.LayerDrawable
+import android.net.Uri
+import android.os.Bundle
+import android.os.Parcelable
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.Fragment
+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.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
+
+    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.getParcelable(ARG_FILE)!!
+        user = arguments.getParcelable(ARG_USER)!!
+
+        if (savedInstanceState != null) {
+            file = savedInstanceState.getParcelable(ARG_FILE)!!
+            user = savedInstanceState.getParcelable(ARG_USER)!!
+            metadata = savedInstanceState.getParcelable(ARG_METADATA)!!
+        }
+
+        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)
+        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<String>()
+        if (metadata.length != null && metadata.width != null && metadata.length!! > 0 && metadata.width!! > 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
+            CoroutineScope(Dispatchers.IO).launch {
+                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 = "${metadata.make} ${metadata.model}"
+        }
+
+        // image taking conditions
+        val imageTakingConditions = mutableListOf<String>()
+        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("Location", "", location)),
+                imagePinDrawable(context),
+                markerOnGestureListener(latitude, longitude),
+                context
+            )
+
+            overlays.add(markerOverlay)
+
+            onResume()
+        }
+
+        // add copyright notice
+        binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice
+    }
+
+    @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 == null || length == null || width <= 0 || length <= 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 {
+        return 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))
+        }
+    }
+
+    @Suppress("MagicNumber")
+    private fun imagePinDrawable(context: Context): LayerDrawable {
+        val bitmap =
+            ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId)
+        val foreground = BitmapUtils.bitmapToCircularBitmapDrawable(resources, bitmap)
+        val background = ContextCompat.getDrawable(context, R.drawable.photo_pin)
+
+        val layerDrawable = if (foreground != null) {
+            LayerDrawable(arrayOf(background, foreground))
+        } else {
+            val d = ContextCompat.getDrawable(context, R.drawable.file_image)
+            LayerDrawable(arrayOf(background, d))
+        }
+
+        val dp = DisplayUtils.convertDpToPixel(2f, context)
+        layerDrawable.apply {
+            setLayerSize(1, 38 * dp, 38 * dp)
+            setLayerSize(0, 40 * dp, 47 * dp)
+            setLayerInsetTop(1, dp)
+            setLayerGravity(1, Gravity.CENTER_HORIZONTAL)
+        }
+        return layerDrawable
+    }
+
+    /**
+     * OnItemGestureListener for marker in MapView.
+     */
+    private fun markerOnGestureListener(latitude: Double, longitude: Double) =
+        object : OnItemGestureListener<OverlayItem> {
+            override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean {
+                val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$latitude,$longitude"))
+                DisplayUtils.startIntentIfAppAvailable(intent, activity, R.string.no_map_app_availble)
+                return true
+            }
+
+            override fun onItemLongPress(index: Int, item: OverlayItem): Boolean {
+                return 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<Double, Double>? = 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 {
+            return ImageDetailFragment().apply {
+                arguments = Bundle().apply {
+                    putParcelable(ARG_FILE, file)
+                    putParcelable(ARG_USER, user)
+                }
+            }
+        }
+    }
+}

+ 14 - 2
app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java

@@ -51,6 +51,8 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation;
 import com.owncloud.android.lib.resources.files.model.FileLockType;
+import com.owncloud.android.lib.resources.files.model.GeoLocation;
+import com.owncloud.android.lib.resources.files.model.ImageDimension;
 import com.owncloud.android.lib.resources.files.model.RemoteFile;
 import com.owncloud.android.lib.resources.shares.OCShare;
 import com.owncloud.android.lib.resources.shares.ShareType;
@@ -440,7 +442,6 @@ public class FileDataStorageManager {
      */
     private ContentValues createContentValuesBase(OCFile fileOrFolder) {
         final ContentValues cv = new ContentValues();
-        final Gson gson = new Gson();
         cv.put(ProviderTableMeta.FILE_MODIFIED, fileOrFolder.getModificationTimestamp());
         cv.put(ProviderTableMeta.FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA, fileOrFolder.getModificationTimestampAtLastSyncForData());
         cv.put(ProviderTableMeta.FILE_PARENT, fileOrFolder.getParentId());
@@ -507,7 +508,8 @@ public class FileDataStorageManager {
         cv.put(ProviderTableMeta.FILE_LOCK_TIMEOUT, file.getLockTimeout());
         cv.put(ProviderTableMeta.FILE_LOCK_TOKEN, file.getLockToken());
         cv.put(ProviderTableMeta.FILE_MODIFIED, file.getModificationTimestamp());
-        cv.put(ProviderTableMeta.FILE_METADATA_SIZE, new Gson().toJson(file.getImageDimension()));
+        cv.put(ProviderTableMeta.FILE_METADATA_SIZE, gson.toJson(file.getImageDimension()));
+        cv.put(ProviderTableMeta.FILE_METADATA_GPS, gson.toJson(file.getGeoLocation()));
 
         return cv;
     }
@@ -978,6 +980,16 @@ public class FileDataStorageManager {
             }
         }
 
+        String metadataGPS = fileEntity.getMetadataGPS();
+        // Surprisingly JSON deserialization causes significant overhead.
+        // Avoid it in common, trivial cases (null/empty).
+        if (!(metadataGPS == null || metadataGPS.isEmpty() || JSON_NULL_STRING.equals(metadataGPS))) {
+            GeoLocation geoLocation = gson.fromJson(metadataGPS, GeoLocation.class);
+            if (geoLocation != null) {
+                ocFile.setGeoLocation(geoLocation);
+            }
+        }
+
         return ocFile;
     }
 

+ 0 - 24
app/src/main/java/com/owncloud/android/datamodel/ImageDimension.kt

@@ -1,24 +0,0 @@
-/*
- *
- * Nextcloud Android client application
- *
- * @author Tobias Kaminsky
- * Copyright (C) 2022 Tobias Kaminsky
- * Copyright (C) 2022 Nextcloud GmbH
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see <https://www.gnu.org/licenses/>.
- */
-package com.owncloud.android.datamodel
-
-data class ImageDimension(var width: Float = -1f, var height: Float = -1f)

+ 15 - 2
app/src/main/java/com/owncloud/android/datamodel/OCFile.java

@@ -34,6 +34,8 @@ import com.owncloud.android.lib.common.network.WebdavEntry;
 import com.owncloud.android.lib.common.network.WebdavUtils;
 import com.owncloud.android.lib.common.utils.Log_OC;
 import com.owncloud.android.lib.resources.files.model.FileLockType;
+import com.owncloud.android.lib.resources.files.model.GeoLocation;
+import com.owncloud.android.lib.resources.files.model.ImageDimension;
 import com.owncloud.android.lib.resources.files.model.ServerFileInterface;
 import com.owncloud.android.lib.resources.shares.ShareeUser;
 import com.owncloud.android.utils.MimeType;
@@ -115,11 +117,13 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
     private String lockToken;
     @Nullable
     private ImageDimension imageDimension;
+    @Nullable
+    private GeoLocation geolocation;
     private List<String> tags = new ArrayList<>();
 
     /**
-     * URI to the local path of the file contents, if stored in the device; cached after first call to {@link
-     * #getStorageUri()}
+     * URI to the local path of the file contents, if stored in the device; cached after first call to
+     * {@link #getStorageUri()}
      */
     private Uri localUri;
 
@@ -975,6 +979,15 @@ public class OCFile implements Parcelable, Comparable<OCFile>, ServerFileInterfa
         return imageDimension;
     }
 
+    public void setGeoLocation(@Nullable GeoLocation geolocation) {
+        this.geolocation = geolocation;
+    }
+
+    @Nullable
+    public GeoLocation getGeoLocation() {
+        return geolocation;
+    }
+
     public List<String> getTags() {
         return tags;
     }

+ 1 - 0
app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java

@@ -55,6 +55,7 @@ import com.owncloud.android.lib.common.OwnCloudClient;
 import com.owncloud.android.lib.common.OwnCloudClientManagerFactory;
 import com.owncloud.android.lib.common.operations.RemoteOperation;
 import com.owncloud.android.lib.common.utils.Log_OC;
+import com.owncloud.android.lib.resources.files.model.ImageDimension;
 import com.owncloud.android.lib.resources.files.model.ServerFileInterface;
 import com.owncloud.android.lib.resources.trashbin.model.TrashbinFile;
 import com.owncloud.android.ui.TextDrawable;

+ 49 - 47
app/src/main/java/com/owncloud/android/db/ProviderMeta.java

@@ -35,7 +35,7 @@ import java.util.List;
  */
 public class ProviderMeta {
     public static final String DB_NAME = "filelist";
-    public static final int DB_VERSION = 71;
+    public static final int DB_VERSION = 72;
 
     private ProviderMeta() {
         // No instance
@@ -117,6 +117,7 @@ public class ProviderMeta {
         public static final String FILE_SHAREES = "sharees";
         public static final String FILE_RICH_WORKSPACE = "rich_workspace";
         public static final String FILE_METADATA_SIZE = "metadata_size";
+        public static final String FILE_METADATA_GPS = "metadata_gps";
         public static final String FILE_LOCKED = "locked";
         public static final String FILE_LOCK_TYPE = "lock_type";
         public static final String FILE_LOCK_OWNER = "lock_owner";
@@ -128,52 +129,53 @@ public class ProviderMeta {
         public static final String FILE_TAGS = "tags";
 
         public static final List<String> FILE_ALL_COLUMNS = Collections.unmodifiableList(Arrays.asList(
-            _ID,
-            FILE_PARENT,
-            FILE_NAME,
-            FILE_ENCRYPTED_NAME,
-            FILE_CREATION,
-            FILE_MODIFIED,
-            FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA,
-            FILE_CONTENT_LENGTH,
-            FILE_CONTENT_TYPE,
-            FILE_STORAGE_PATH,
-            FILE_PATH,
-            FILE_PATH_DECRYPTED,
-            FILE_ACCOUNT_OWNER,
-            FILE_LAST_SYNC_DATE,
-            FILE_LAST_SYNC_DATE_FOR_DATA,
-            FILE_KEEP_IN_SYNC,
-            FILE_ETAG,
-            FILE_ETAG_ON_SERVER,
-            FILE_SHARED_VIA_LINK,
-            FILE_SHARED_WITH_SHAREE,
-            FILE_PERMISSIONS,
-            FILE_REMOTE_ID,
-            FILE_LOCAL_ID,
-            FILE_UPDATE_THUMBNAIL,
-            FILE_IS_DOWNLOADING,
-            FILE_ETAG_IN_CONFLICT,
-            FILE_FAVORITE,
-            FILE_IS_ENCRYPTED,
-            FILE_MOUNT_TYPE,
-            FILE_HAS_PREVIEW,
-            FILE_UNREAD_COMMENTS_COUNT,
-            FILE_OWNER_ID,
-            FILE_OWNER_DISPLAY_NAME,
-            FILE_NOTE,
-            FILE_SHAREES,
-            FILE_RICH_WORKSPACE,
-            FILE_LOCKED,
-            FILE_LOCK_TYPE,
-            FILE_LOCK_OWNER,
-            FILE_LOCK_OWNER_DISPLAY_NAME,
-            FILE_LOCK_OWNER_EDITOR,
-            FILE_LOCK_TIMESTAMP,
-            FILE_LOCK_TIMEOUT,
-            FILE_LOCK_TOKEN,
-            FILE_METADATA_SIZE,
-            FILE_TAGS));
+                _ID,
+                FILE_PARENT,
+                FILE_NAME,
+                FILE_ENCRYPTED_NAME,
+                FILE_CREATION,
+                FILE_MODIFIED,
+                FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA,
+                FILE_CONTENT_LENGTH,
+                FILE_CONTENT_TYPE,
+                FILE_STORAGE_PATH,
+                FILE_PATH,
+                FILE_PATH_DECRYPTED,
+                FILE_ACCOUNT_OWNER,
+                FILE_LAST_SYNC_DATE,
+                FILE_LAST_SYNC_DATE_FOR_DATA,
+                FILE_KEEP_IN_SYNC,
+                FILE_ETAG,
+                FILE_ETAG_ON_SERVER,
+                FILE_SHARED_VIA_LINK,
+                FILE_SHARED_WITH_SHAREE,
+                FILE_PERMISSIONS,
+                FILE_REMOTE_ID,
+                FILE_LOCAL_ID,
+                FILE_UPDATE_THUMBNAIL,
+                FILE_IS_DOWNLOADING,
+                FILE_ETAG_IN_CONFLICT,
+                FILE_FAVORITE,
+                FILE_IS_ENCRYPTED,
+                FILE_MOUNT_TYPE,
+                FILE_HAS_PREVIEW,
+                FILE_UNREAD_COMMENTS_COUNT,
+                FILE_OWNER_ID,
+                FILE_OWNER_DISPLAY_NAME,
+                FILE_NOTE,
+                FILE_SHAREES,
+                FILE_RICH_WORKSPACE,
+                FILE_LOCKED,
+                FILE_LOCK_TYPE,
+                FILE_LOCK_OWNER,
+                FILE_LOCK_OWNER_DISPLAY_NAME,
+                FILE_LOCK_OWNER_EDITOR,
+                FILE_LOCK_TIMESTAMP,
+                FILE_LOCK_TIMEOUT,
+                FILE_LOCK_TOKEN,
+                FILE_METADATA_SIZE,
+                FILE_TAGS,
+                FILE_METADATA_GPS));
         public static final String FILE_DEFAULT_SORT_ORDER = FILE_NAME + " collate nocase asc";
 
         // Columns of ocshares table

+ 17 - 6
app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java

@@ -21,10 +21,12 @@
 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.EncryptionUtils;
+import com.owncloud.android.utils.MimeTypeUtil;
 
 import androidx.annotation.NonNull;
 import androidx.fragment.app.Fragment;
@@ -40,6 +42,7 @@ public class FileDetailTabAdapter extends FragmentStatePagerAdapter {
 
     private FileDetailSharingFragment fileDetailSharingFragment;
     private FileDetailActivitiesFragment fileDetailActivitiesFragment;
+    private ImageDetailFragment imageDetailFragment;
 
     public FileDetailTabAdapter(FragmentManager fm, OCFile file, User user) {
         super(fm);
@@ -58,6 +61,9 @@ public class FileDetailTabAdapter extends FragmentStatePagerAdapter {
             case 1:
                 fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user);
                 return fileDetailSharingFragment;
+            case 2:
+                imageDetailFragment = ImageDetailFragment.newInstance(file, user);
+                return imageDetailFragment;
         }
     }
 
@@ -69,18 +75,23 @@ public class FileDetailTabAdapter extends FragmentStatePagerAdapter {
         return fileDetailActivitiesFragment;
     }
 
+    public ImageDetailFragment getImageDetailFragment() {
+        return imageDetailFragment;
+    }
+
     @Override
     public int getCount() {
         if (file.isEncrypted()) {
             if (EncryptionUtils.supportsSecureFiledrop(file, user)) {
                 return 2;
-            } else {
-                // sharing not allowed for encrypted files, thus only show first tab (activities)
-                return 1;
             }
-        } else {
-            // unencrypted files/folders
-            return 2;
+            // sharing not allowed for encrypted files, thus only show first tab (activities)
+            return 1;
+        }
+        // unencrypted files/folders
+        if (MimeTypeUtil.isImage(file)) {
+            return 3;
         }
+        return 2;
     }
 }

+ 1 - 1
app/src/main/java/com/owncloud/android/ui/adapter/GalleryRowHolder.kt

@@ -32,9 +32,9 @@ import com.owncloud.android.R
 import com.owncloud.android.databinding.GalleryRowBinding
 import com.owncloud.android.datamodel.FileDataStorageManager
 import com.owncloud.android.datamodel.GalleryRow
-import com.owncloud.android.datamodel.ImageDimension
 import com.owncloud.android.datamodel.OCFile
 import com.owncloud.android.datamodel.ThumbnailsCacheManager
+import com.owncloud.android.lib.resources.files.model.ImageDimension
 import com.owncloud.android.utils.BitmapUtils
 import com.owncloud.android.utils.DisplayUtils
 

+ 9 - 0
app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java

@@ -304,6 +304,10 @@ public class FileDetailFragment extends FileFragment implements OnClickListener,
             binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.shared_via_users));
         }
 
+        if (MimeTypeUtil.isImage(getFile())) {
+            binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.image_32dp));
+        }
+
         viewThemeUtils.material.themeTabLayout(binding.tabLayout);
 
         final FileDetailTabAdapter adapter = new FileDetailTabAdapter(getFragmentManager(), getFile(), user);
@@ -543,6 +547,11 @@ public class FileDetailFragment extends FileFragment implements OnClickListener,
                 // remains there
                 setButtonsForRemote();
             }
+
+            FloatingActionButton fabMain = requireActivity().findViewById(R.id.fab_main);
+            if (fabMain != null) {
+                fabMain.hide();
+            }
         }
 
         setupViewPager();

+ 9 - 0
app/src/main/java/com/owncloud/android/utils/BitmapUtils.java

@@ -205,6 +205,13 @@ public final class BitmapUtils {
         return resultBitmap;
     }
 
+    public static int[] getImageResolution(String srcPath) {
+        Options options = new Options();
+        options.inJustDecodeBounds = true;
+        BitmapFactory.decodeFile(srcPath, options);
+        return new int [] {options.outWidth, options.outHeight};
+    }
+
     public static Color usernameToColor(String name) {
         String hash = name.toLowerCase(Locale.ROOT);
 
@@ -346,6 +353,7 @@ public final class BitmapUtils {
      * @param bitmap    the original bitmap
      * @return the circular bitmap
      */
+    @Nullable
     public static RoundedBitmapDrawable bitmapToCircularBitmapDrawable(Resources resources,
                                                                        Bitmap bitmap,
                                                                        float radius) {
@@ -363,6 +371,7 @@ public final class BitmapUtils {
         return roundedBitmap;
     }
 
+    @Nullable
     public static RoundedBitmapDrawable bitmapToCircularBitmapDrawable(Resources resources, Bitmap bitmap) {
         return bitmapToCircularBitmapDrawable(resources, bitmap, -1);
     }

+ 2 - 0
app/src/main/java/com/owncloud/android/utils/FileStorageUtils.java

@@ -245,6 +245,8 @@ public final class FileStorageUtils {
         file.setLockTimeout(remote.getLockTimeout());
         file.setLockToken(remote.getLockToken());
         file.setTags(new ArrayList<>(Arrays.asList(remote.getTags())));
+        file.setImageDimension(remote.getImageDimension());
+        file.setGeoLocation(remote.getGeoLocation());
 
         return file;
     }

+ 5 - 0
app/src/main/res/drawable/outline_camera_24.xml

@@ -0,0 +1,5 @@
+<vector android:autoMirrored="true" android:height="24dp"
+    android:tint="#000000" android:viewportHeight="24"
+    android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M14.25,2.26l-0.08,-0.04 -0.01,0.02C13.46,2.09 12.74,2 12,2 6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10c0,-4.75 -3.31,-8.72 -7.75,-9.74zM19.41,9h-7.99l2.71,-4.7c2.4,0.66 4.35,2.42 5.28,4.7zM13.1,4.08L10.27,9l-1.15,2L6.4,6.3C7.84,4.88 9.82,4 12,4c0.37,0 0.74,0.03 1.1,0.08zM5.7,7.09L8.54,12l1.15,2L4.26,14C4.1,13.36 4,12.69 4,12c0,-1.85 0.64,-3.55 1.7,-4.91zM4.59,15h7.98l-2.71,4.7c-2.4,-0.67 -4.34,-2.42 -5.27,-4.7zM10.9,19.91L14.89,13l2.72,4.7C16.16,19.12 14.18,20 12,20c-0.38,0 -0.74,-0.04 -1.1,-0.09zM18.3,16.91l-4,-6.91h5.43c0.17,0.64 0.27,1.31 0.27,2 0,1.85 -0.64,3.55 -1.7,4.91z"/>
+</vector>

+ 5 - 0
app/src/main/res/drawable/outline_image_24.xml

@@ -0,0 +1,5 @@
+<vector android:autoMirrored="true" android:height="24dp"
+    android:tint="#000000" android:viewportHeight="24"
+    android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M19,5v14L5,19L5,5h14m0,-2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM14.14,11.86l-3,3.87L9,13.14 6,17h12l-3.86,-5.14z"/>
+</vector>

+ 36 - 0
app/src/main/res/drawable/photo_pin.xml

@@ -0,0 +1,36 @@
+<!--
+ Nextcloud Android client application
+
+ @author ZetaTom
+ Copyright (C) 2023 ZetaTom
+ Copyright (C) 2023 Nextcloud GmbH
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="40dp"
+    android:height="46.61dp"
+    android:viewportWidth="40"
+    android:viewportHeight="46.61">
+    <path
+        android:fillColor="@color/grey_600"
+        android:pathData="M1.523,20a18.477,18.477 0,1 0,36.954 0a18.477,18.477 0,1 0,-36.954 0z"
+        android:strokeWidth="3.0454"
+        android:strokeColor="@color/grey_600" />
+    <path
+        android:fillColor="@color/grey_600"
+        android:pathData="m1.5,20c0,17.795 18.5,25 18.5,25s18.5,-7.205 18.5,-25"
+        android:strokeWidth="3"
+        android:strokeColor="@color/grey_600" />
+</vector>

+ 171 - 0
app/src/main/res/layout/preview_image_details_fragment.xml

@@ -0,0 +1,171 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+ Nextcloud Android client application
+
+ @author ZetaTom
+ Copyright (C) 2023 ZetaTom
+ Copyright (C) 2023 Nextcloud GmbH
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+-->
+<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical"
+        android:padding="16dp">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:paddingHorizontal="16dp">
+
+            <LinearLayout
+                android:id="@+id/fileInformation"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:gravity="center_vertical"
+                android:orientation="horizontal"
+                android:paddingBottom="16dp"
+                android:visibility="gone"
+                tools:visibility="visible">
+
+                <ImageView
+                    android:id="@+id/file_details_icon"
+                    android:layout_width="48dp"
+                    android:layout_height="48dp"
+                    android:contentDescription="@string/image_preview_filedetails"
+                    android:padding="6dp"
+
+                    android:src="@drawable/outline_image_24" />
+
+                <LinearLayout
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:orientation="vertical"
+                    android:paddingHorizontal="12dp">
+
+                    <TextView
+                        android:id="@+id/fileInformation_time"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:padding="2dp"
+                        android:textStyle="bold"
+                        tools:text="Wednesday • 26 Jul 2023 • 12:27" />
+
+                    <TextView
+                        android:id="@+id/fileInformation_details"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:padding="2dp"
+                        tools:text="12 MP • 3024 × 4032 • 923 KB" />
+                </LinearLayout>
+            </LinearLayout>
+
+            <LinearLayout
+                android:id="@+id/imgTC"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:gravity="center_vertical"
+                android:orientation="horizontal"
+                android:paddingBottom="16dp"
+                android:visibility="gone"
+                tools:visibility="visible">
+
+                <ImageView
+                    android:id="@+id/camera_information_icon"
+                    android:layout_width="48dp"
+                    android:layout_height="48dp"
+                    android:contentDescription="@string/image_preview_image_taking_conditions"
+                    android:padding="6dp"
+                    android:src="@drawable/outline_camera_24" />
+
+                <LinearLayout
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:orientation="vertical"
+                    android:paddingHorizontal="12dp">
+
+                    <TextView
+                        android:id="@+id/imgTC_makeModel"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:padding="2dp"
+                        android:textStyle="bold"
+                        tools:text="Camera Phone (4th generation)" />
+
+                    <TextView
+                        android:id="@+id/imgTC_conditions"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:padding="2dp"
+                        tools:text="ƒ/1.8 • 1/374 s • 28 mm • ISO 200" />
+                </LinearLayout>
+            </LinearLayout>
+
+        </LinearLayout>
+
+        <com.google.android.material.card.MaterialCardView
+            android:id="@+id/imageLocation"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:visibility="gone"
+            tools:visibility="visible">
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="330dp"
+                android:background="@drawable/rounded_rect"
+                android:elevation="1dp"
+                android:orientation="vertical">
+
+                <TextView
+                    android:id="@+id/imageLocation_text"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_gravity="center"
+                    android:padding="6dp"
+                    android:textStyle="bold"
+                    android:visibility="gone"
+                    tools:text="Mitte, Berlin, Germany"
+                    tools:visibility="visible" />
+
+                <FrameLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="match_parent">
+
+                    <org.osmdroid.views.MapView
+                        android:id="@+id/imageLocation_map"
+                        android:layout_width="match_parent"
+                        android:layout_height="match_parent" />
+
+                    <TextView
+                        android:id="@+id/imageLocation_map_copyright"
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:layout_gravity="bottom|end"
+                        android:padding="5dp"
+                        tools:text="© OpenStreetMap contributors" />
+                </FrameLayout>
+
+            </LinearLayout>
+
+        </com.google.android.material.card.MaterialCardView>
+
+    </LinearLayout>
+</androidx.core.widget.NestedScrollView>

+ 5 - 0
app/src/main/res/values/setup.xml

@@ -121,6 +121,11 @@
     <string name="splashScreenBold"></string>
     <string name="splashScreenNormal"></string>
 
+    <!-- Geocoding -->
+    <string name="osm_geocoder_url" translatable="false">https://nominatim.openstreetmap.org/</string>
+    <string name="osm_geocoder_contact" translatable="false">android@nextcloud.com</string>
+
+
     <!-- Dev settings -->
     <string name="dev_link">https://download.nextcloud.com/android/dev/nextcloud-dev-</string>
     <string name="dev_latest">https://download.nextcloud.com/android/dev/latest</string>

+ 9 - 0
app/src/main/res/values/strings.xml

@@ -805,6 +805,7 @@
     <string name="no_browser_available">No app available to handle links</string>
     <string name="no_pdf_app_available">No App available to handle PDF</string>
     <string name="no_email_app_available">No App available to handle mail address</string>
+    <string name="no_map_app_availble">No App available to handle maps</string>
     <string name="share_via_link_hide_download">Hide download</string>
     <string name="unread_comments">Unread comments exist</string>
     <string name="richdocuments_failed_to_load_document">Failed to load document!</string>
@@ -1098,4 +1099,12 @@
     <string name="ecosystem_apps_display_notes">Notes</string>
     <string name="ecosystem_apps_display_talk">Talk</string>
     <string name="ecosystem_apps_display_more">More</string>
+    <string name="filedetails_details">Details</string>
+    <string name="image_preview_unit_millimetres">%s mm</string>
+    <string name="image_preview_unit_fnumber">ƒ/%s</string>
+    <string name="image_preview_unit_seconds">%s s</string>
+    <string name="image_preview_unit_iso">ISO %s</string>
+    <string name="image_preview_unit_megapixel">%s MP</string>
+    <string name="image_preview_filedetails">File details</string>
+    <string name="image_preview_image_taking_conditions">Image taking conditions</string>
 </resources>