ImageDetailFragment.kt 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. /*
  2. * Nextcloud - Android Client
  3. *
  4. * SPDX-FileCopyrightText: 2023 ZetaTom
  5. * SPDX-FileCopyrightText: 2023 Nextcloud GmbH
  6. * SPDX-License-Identifier: AGPL-3.0-or-later
  7. */
  8. package com.nextcloud.ui
  9. import android.annotation.SuppressLint
  10. import android.content.Context
  11. import android.content.Intent
  12. import android.graphics.drawable.LayerDrawable
  13. import android.net.Uri
  14. import android.os.Bundle
  15. import android.os.Parcelable
  16. import android.view.LayoutInflater
  17. import android.view.View
  18. import android.view.ViewGroup
  19. import androidx.annotation.VisibleForTesting
  20. import androidx.core.content.ContextCompat
  21. import androidx.fragment.app.Fragment
  22. import com.nextcloud.android.common.ui.theme.utils.ColorRole
  23. import com.nextcloud.client.NominatimClient
  24. import com.nextcloud.client.account.User
  25. import com.nextcloud.client.di.Injectable
  26. import com.nextcloud.utils.extensions.getParcelableArgument
  27. import com.owncloud.android.MainApp
  28. import com.owncloud.android.R
  29. import com.owncloud.android.databinding.PreviewImageDetailsFragmentBinding
  30. import com.owncloud.android.datamodel.OCFile
  31. import com.owncloud.android.datamodel.ThumbnailsCacheManager
  32. import com.owncloud.android.utils.BitmapUtils
  33. import com.owncloud.android.utils.DisplayUtils
  34. import com.owncloud.android.utils.theme.ViewThemeUtils
  35. import kotlinx.coroutines.CoroutineScope
  36. import kotlinx.coroutines.Dispatchers
  37. import kotlinx.coroutines.launch
  38. import kotlinx.coroutines.withContext
  39. import kotlinx.parcelize.Parcelize
  40. import org.osmdroid.config.Configuration
  41. import org.osmdroid.tileprovider.tilesource.TileSourceFactory
  42. import org.osmdroid.util.GeoPoint
  43. import org.osmdroid.views.CustomZoomButtonsController
  44. import org.osmdroid.views.overlay.ItemizedIconOverlay
  45. import org.osmdroid.views.overlay.ItemizedIconOverlay.OnItemGestureListener
  46. import org.osmdroid.views.overlay.OverlayItem
  47. import java.lang.Long.max
  48. import java.text.DateFormat
  49. import java.text.SimpleDateFormat
  50. import java.util.Locale
  51. import javax.inject.Inject
  52. import kotlin.math.pow
  53. import kotlin.math.roundToInt
  54. class ImageDetailFragment : Fragment(), Injectable {
  55. private lateinit var binding: PreviewImageDetailsFragmentBinding
  56. private lateinit var file: OCFile
  57. private lateinit var user: User
  58. private lateinit var metadata: ImageMetadata
  59. private lateinit var nominatimClient: NominatimClient
  60. @Inject
  61. lateinit var viewThemeUtils: ViewThemeUtils
  62. override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
  63. binding = PreviewImageDetailsFragmentBinding.inflate(layoutInflater, container, false)
  64. binding.fileDetailsIcon.setImageDrawable(
  65. viewThemeUtils.platform.tintDrawable(
  66. requireContext(),
  67. R.drawable.outline_image_24,
  68. ColorRole.ON_BACKGROUND
  69. )
  70. )
  71. binding.cameraInformationIcon.setImageDrawable(
  72. viewThemeUtils.platform.tintDrawable(
  73. requireContext(),
  74. R.drawable.outline_camera_24,
  75. ColorRole.ON_BACKGROUND
  76. )
  77. )
  78. val arguments = arguments ?: throw IllegalStateException("arguments are mandatory")
  79. file = arguments.getParcelableArgument(ARG_FILE, OCFile::class.java)!!
  80. user = arguments.getParcelableArgument(ARG_USER, User::class.java)!!
  81. if (savedInstanceState != null) {
  82. file = savedInstanceState.getParcelableArgument(ARG_FILE, OCFile::class.java)!!
  83. user = savedInstanceState.getParcelableArgument(ARG_USER, User::class.java)!!
  84. metadata = savedInstanceState.getParcelableArgument(ARG_METADATA, ImageMetadata::class.java)!!
  85. }
  86. nominatimClient = NominatimClient(
  87. getString(R.string.osm_geocoder_url), getString(R.string.osm_geocoder_contact)
  88. )
  89. return binding.root
  90. }
  91. override fun onSaveInstanceState(outState: Bundle) {
  92. super.onSaveInstanceState(outState)
  93. outState.putParcelable(ARG_FILE, file)
  94. outState.putParcelable(ARG_USER, user)
  95. outState.putParcelable(ARG_METADATA, metadata)
  96. }
  97. override fun onStart() {
  98. super.onStart()
  99. gatherMetadata()
  100. setupFragment()
  101. }
  102. @SuppressLint("LongMethod")
  103. private fun setupFragment() {
  104. binding.fileInformationTime.text = metadata.date
  105. // detailed file information
  106. val fileInformation = mutableListOf<String>()
  107. if ((metadata.length ?: 0) > 0 && (metadata.width ?: 0) > 0) {
  108. try {
  109. @Suppress("MagicNumber")
  110. val pxlCount = when (val res = metadata.length!! * metadata.width!!.toLong()) {
  111. in 0..999999 -> "%.2f".format(res / 1000000f)
  112. in 1000000..9999999 -> "%.1f".format(res / 1000000f)
  113. else -> (res / 1000000).toString()
  114. }
  115. fileInformation.add(String.format(getString(R.string.image_preview_unit_megapixel), pxlCount))
  116. fileInformation.add("${metadata.width!!} × ${metadata.length!!}")
  117. } catch (_: NumberFormatException) {
  118. }
  119. }
  120. metadata.fileSize?.let { fileInformation.add(it) }
  121. if (fileInformation.isNotEmpty()) {
  122. binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP)
  123. binding.fileInformation.visibility = View.VISIBLE
  124. }
  125. setImageTakenConditions()
  126. // initialise map and address views
  127. metadata.location?.let { location ->
  128. initMap(location.first, location.second)
  129. binding.imageLocation.visibility = View.VISIBLE
  130. // launch reverse geocoding request
  131. CoroutineScope(Dispatchers.IO).launch {
  132. val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second)
  133. if (geocodingResult != null) {
  134. withContext(Dispatchers.Main) {
  135. binding.imageLocationText.visibility = View.VISIBLE
  136. binding.imageLocationText.text = geocodingResult.displayName
  137. }
  138. }
  139. }
  140. }
  141. }
  142. private fun setImageTakenConditions() {
  143. // camera make and model
  144. val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) {
  145. "${metadata.make} ${metadata.model}"
  146. } else {
  147. metadata.model ?: metadata.make
  148. }
  149. if (metadata.make == null || metadata.model?.contains(metadata.make!!) == true) {
  150. binding.imgTCMakeModel.text = metadata.model
  151. } else {
  152. binding.imgTCMakeModel.text = String.format(
  153. getString(R.string.make_model),
  154. metadata.make,
  155. metadata.model
  156. )
  157. }
  158. // image taking conditions
  159. val imageTakingConditions = mutableListOf<String>()
  160. metadata.aperture?.let {
  161. imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_fnumber), it))
  162. }
  163. metadata.exposure?.let {
  164. imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_seconds), it))
  165. }
  166. metadata.focalLen?.let {
  167. imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_millimetres), it))
  168. }
  169. metadata.iso?.let {
  170. imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_iso), it))
  171. }
  172. if (imageTakingConditions.isNotEmpty() && makeModel != null) {
  173. binding.imgTCMakeModel.text = makeModel
  174. binding.imgTCConditions.text = imageTakingConditions.joinToString(separator = TEXT_SEP)
  175. binding.imgTC.visibility = View.VISIBLE
  176. }
  177. }
  178. @SuppressLint("ClickableViewAccessibility")
  179. private fun initMap(latitude: Double, longitude: Double, zoom: Double = 13.0) {
  180. // required for OpenStreetMap
  181. Configuration.getInstance().userAgentValue = MainApp.getUserAgent()
  182. val location = GeoPoint(latitude, longitude)
  183. binding.imageLocationMap.apply {
  184. setTileSource(TileSourceFactory.MAPNIK)
  185. // set expected boundaries
  186. setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0)
  187. isVerticalMapRepetitionEnabled = false
  188. minZoomLevel = 2.0
  189. maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble()
  190. // initial location
  191. controller.setCenter(location)
  192. controller.setZoom(zoom)
  193. // scale labels to be legible
  194. isTilesScaledToDpi = true
  195. setZoomRounding(true)
  196. // hide zoom buttons
  197. zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
  198. // enable multi-touch zoom
  199. setMultiTouchControls(true)
  200. setOnTouchListener { v, _ ->
  201. v.parent.requestDisallowInterceptTouchEvent(true)
  202. false
  203. }
  204. val markerOverlay = ItemizedIconOverlay(
  205. mutableListOf(OverlayItem(null, null, location)),
  206. imagePinDrawable(context),
  207. markerOnGestureListener(latitude, longitude),
  208. context
  209. )
  210. overlays.add(markerOverlay)
  211. onResume()
  212. }
  213. // add copyright notice
  214. binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice
  215. }
  216. @VisibleForTesting
  217. fun hideMap() {
  218. binding.imageLocationMap.visibility = View.GONE
  219. }
  220. @SuppressLint("SimpleDateFormat")
  221. private fun gatherMetadata() {
  222. val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength)
  223. var timestamp = max(file.modificationTimestamp, file.creationTimestamp)
  224. if (file.isDown) {
  225. val exif = androidx.exifinterface.media.ExifInterface(file.storagePath)
  226. var length = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_LENGTH)?.toInt()
  227. var width = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_WIDTH)?.toInt()
  228. var exposure = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_SHUTTER_SPEED_VALUE)
  229. // get timestamp from date string
  230. exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_DATETIME)?.let {
  231. timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp
  232. }
  233. // format exposure string
  234. if (exposure == null) {
  235. exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_EXPOSURE_TIME)?.let {
  236. exposure = "1/" + (1 / it.toDouble()).toInt()
  237. }
  238. } else if ("/" in exposure!!) {
  239. try {
  240. exposure!!.split("/").also {
  241. exposure = "1/" + 2f.pow(it[0].toFloat() / it[1].toFloat()).roundToInt()
  242. }
  243. } catch (_: NumberFormatException) {
  244. }
  245. }
  246. // determine size if not contained in exif data
  247. if ((width ?: 0) <= 0 || (length ?: 0) <= 0) {
  248. val res = BitmapUtils.getImageResolution(file.storagePath)
  249. width = res[0]
  250. length = res[1]
  251. }
  252. metadata = ImageMetadata(
  253. fileSize = fileSize,
  254. length = length,
  255. width = width,
  256. exposure = exposure,
  257. date = formatDate(timestamp),
  258. location = exif.latLong?.let { Pair(it[0], it[1]) },
  259. aperture = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_F_NUMBER),
  260. focalLen = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM),
  261. make = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MAKE),
  262. model = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MODEL),
  263. iso = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_ISO_SPEED) ?: exif.getAttribute(
  264. androidx.exifinterface.media.ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY
  265. )
  266. )
  267. } else {
  268. // get metadata from server
  269. val location = if (file.geoLocation == null) {
  270. null
  271. } else {
  272. Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude)
  273. }
  274. metadata = ImageMetadata(
  275. fileSize = fileSize,
  276. date = formatDate(timestamp),
  277. location = location,
  278. width = file.imageDimension?.width?.toInt(),
  279. length = file.imageDimension?.height?.toInt()
  280. )
  281. }
  282. }
  283. @SuppressLint("SimpleDateFormat")
  284. private fun formatDate(timestamp: Long): String {
  285. return buildString {
  286. append(SimpleDateFormat("EEEE").format(timestamp))
  287. append(TEXT_SEP)
  288. append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp))
  289. append(TEXT_SEP)
  290. append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp))
  291. }
  292. }
  293. private fun imagePinDrawable(context: Context): LayerDrawable {
  294. val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable
  295. val bitmap =
  296. ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId)
  297. BitmapUtils.bitmapToCircularBitmapDrawable(resources, bitmap)?.let {
  298. drawable.setDrawable(1, it)
  299. }
  300. return drawable
  301. }
  302. /**
  303. * OnItemGestureListener for marker in MapView.
  304. */
  305. private fun markerOnGestureListener(latitude: Double, longitude: Double) =
  306. object : OnItemGestureListener<OverlayItem> {
  307. override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean {
  308. val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=$latitude,$longitude"))
  309. DisplayUtils.startIntentIfAppAvailable(intent, activity, R.string.no_map_app_availble)
  310. return true
  311. }
  312. override fun onItemLongPress(index: Int, item: OverlayItem): Boolean {
  313. return false
  314. }
  315. }
  316. @Parcelize
  317. private data class ImageMetadata(
  318. val fileSize: String? = null,
  319. val date: String? = null,
  320. val length: Int? = null,
  321. val width: Int? = null,
  322. val exposure: String? = null,
  323. val aperture: String? = null,
  324. val focalLen: String? = null,
  325. val iso: String? = null,
  326. val make: String? = null,
  327. val model: String? = null,
  328. val location: Pair<Double, Double>? = null
  329. ) : Parcelable
  330. companion object {
  331. private const val ARG_FILE = "FILE"
  332. private const val ARG_USER = "USER"
  333. private const val ARG_METADATA = "METADATA"
  334. private const val TEXT_SEP = " • "
  335. private const val SCROLL_LIMIT = 80.0
  336. @JvmStatic
  337. fun newInstance(file: OCFile, user: User): ImageDetailFragment {
  338. return ImageDetailFragment().apply {
  339. arguments = Bundle().apply {
  340. putParcelable(ARG_FILE, file)
  341. putParcelable(ARG_USER, user)
  342. }
  343. }
  344. }
  345. }
  346. }