diff --git a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt index da634d4d62c6..94fe57651bc5 100644 --- a/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt +++ b/app/src/androidTest/java/com/owncloud/android/ui/fragment/FileDetailFragmentStaticServerIT.kt @@ -15,7 +15,7 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.isRoot import com.nextcloud.test.TestActivity -import com.nextcloud.ui.ImageDetailFragment +import com.nextcloud.ui.fileInfo.FileInfoFragment import com.owncloud.android.AbstractIT import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile @@ -79,7 +79,7 @@ class FileDetailFragmentStaticServerIT : AbstractIT() { var activity: TestActivity? = null scenario.onActivity { sut -> activity = sut - val fragment = ImageDetailFragment.newInstance(oCFile, user).apply { + val fragment = FileInfoFragment.newInstance(oCFile, user).apply { hideMap() } sut.addFragment(fragment) diff --git a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java index 42be74854cca..2643b10e530a 100644 --- a/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java +++ b/app/src/main/java/com/nextcloud/client/di/ComponentsModule.java @@ -29,7 +29,7 @@ import com.nextcloud.receiver.NetworkChangeReceiver; import com.nextcloud.ui.ChooseAccountDialogFragment; import com.nextcloud.ui.ChooseStorageLocationDialogFragment; -import com.nextcloud.ui.ImageDetailFragment; +import com.nextcloud.ui.fileInfo.FileInfoFragment; import com.nextcloud.ui.SetOnlineStatusBottomSheet; import com.nextcloud.ui.SetStatusMessageBottomSheet; import com.nextcloud.ui.composeActivity.ComposeActivity; @@ -487,7 +487,7 @@ abstract class ComponentsModule { abstract EditImageActivity editImageActivity(); @ContributesAndroidInjector - abstract ImageDetailFragment imageDetailFragment(); + abstract FileInfoFragment fileInfoFragment(); @ContributesAndroidInjector abstract EtmBackgroundJobsFragment etmBackgroundJobsFragment(); diff --git a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt deleted file mode 100644 index 766809e8247c..000000000000 --- a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt +++ /dev/null @@ -1,398 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2023 ZetaTom - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.nextcloud.ui - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.graphics.drawable.LayerDrawable -import android.os.Bundle -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.VisibleForTesting -import androidx.core.content.ContextCompat -import androidx.core.net.toUri -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import com.nextcloud.android.common.ui.theme.utils.ColorRole -import com.nextcloud.client.NominatimClient -import com.nextcloud.client.account.User -import com.nextcloud.client.di.Injectable -import com.nextcloud.utils.extensions.getParcelableArgument -import com.nextcloud.utils.extensions.logFileSize -import com.owncloud.android.MainApp -import com.owncloud.android.R -import com.owncloud.android.databinding.PreviewImageDetailsFragmentBinding -import com.owncloud.android.datamodel.OCFile -import com.owncloud.android.datamodel.ThumbnailsCacheManager -import com.owncloud.android.utils.BitmapUtils -import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.theme.ViewThemeUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize -import org.osmdroid.config.Configuration -import org.osmdroid.tileprovider.tilesource.TileSourceFactory -import org.osmdroid.util.GeoPoint -import org.osmdroid.views.CustomZoomButtonsController -import org.osmdroid.views.overlay.ItemizedIconOverlay -import org.osmdroid.views.overlay.ItemizedIconOverlay.OnItemGestureListener -import org.osmdroid.views.overlay.OverlayItem -import java.lang.Long.max -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Locale -import javax.inject.Inject -import kotlin.math.pow -import kotlin.math.roundToInt - -class ImageDetailFragment : - Fragment(), - Injectable { - private lateinit var binding: PreviewImageDetailsFragmentBinding - private lateinit var file: OCFile - private lateinit var user: User - private lateinit var metadata: ImageMetadata - private lateinit var nominatimClient: NominatimClient - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private val tag = "ImageDetailFragment" - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = PreviewImageDetailsFragmentBinding.inflate(layoutInflater, container, false) - - binding.fileDetailsIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_image_24, - ColorRole.ON_BACKGROUND - ) - ) - - binding.cameraInformationIcon.setImageDrawable( - viewThemeUtils.platform.tintDrawable( - requireContext(), - R.drawable.outline_camera_24, - ColorRole.ON_BACKGROUND - ) - ) - - val arguments = arguments ?: throw IllegalStateException("arguments are mandatory") - file = arguments.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = arguments.getParcelableArgument(ARG_USER, User::class.java)!! - - if (savedInstanceState != null) { - file = savedInstanceState.getParcelableArgument(ARG_FILE, OCFile::class.java)!! - user = savedInstanceState.getParcelableArgument(ARG_USER, User::class.java)!! - metadata = savedInstanceState.getParcelableArgument(ARG_METADATA, ImageMetadata::class.java)!! - } - - nominatimClient = NominatimClient( - getString(R.string.osm_geocoder_url), - getString(R.string.osm_geocoder_contact) - ) - - return binding.root - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - file.logFileSize(tag) - outState.putParcelable(ARG_FILE, file) - outState.putParcelable(ARG_USER, user) - outState.putParcelable(ARG_METADATA, metadata) - } - - override fun onStart() { - super.onStart() - gatherMetadata() - setupFragment() - } - - @SuppressLint("LongMethod") - private fun setupFragment() { - binding.fileInformationTime.text = metadata.date - - // detailed file information - val fileInformation = mutableListOf() - if ((metadata.length ?: 0) > 0 && (metadata.width ?: 0) > 0) { - try { - @Suppress("MagicNumber") - val pxlCount = when (val res = metadata.length!! * metadata.width!!.toLong()) { - in 0..999999 -> "%.2f".format(res / 1000000f) - in 1000000..9999999 -> "%.1f".format(res / 1000000f) - else -> (res / 1000000).toString() - } - - fileInformation.add(String.format(getString(R.string.image_preview_unit_megapixel), pxlCount)) - fileInformation.add("${metadata.width!!} × ${metadata.length!!}") - } catch (_: NumberFormatException) { - } - } - metadata.fileSize?.let { fileInformation.add(it) } - - if (fileInformation.isNotEmpty()) { - binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP) - binding.fileInformation.visibility = View.VISIBLE - } - - setImageTakenConditions() - - // initialise map and address views - metadata.location?.let { location -> - initMap(location.first, location.second) - binding.imageLocation.visibility = View.VISIBLE - - // launch reverse geocoding request - lifecycleScope.launch(Dispatchers.IO) { - val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second) - if (geocodingResult != null) { - withContext(Dispatchers.Main) { - binding.imageLocationText.visibility = View.VISIBLE - binding.imageLocationText.text = geocodingResult.displayName - } - } - } - } - } - - private fun setImageTakenConditions() { - // camera make and model - val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) { - "${metadata.make} ${metadata.model}" - } else { - metadata.model ?: metadata.make - } - - if (metadata.make == null || metadata.model?.contains(metadata.make!!) == true) { - binding.imgTCMakeModel.text = metadata.model - } else { - binding.imgTCMakeModel.text = String.format( - getString(R.string.make_model), - metadata.make, - metadata.model - ) - } - - // image taking conditions - val imageTakingConditions = mutableListOf() - metadata.aperture?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_fnumber), it)) - } - metadata.exposure?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_seconds), it)) - } - metadata.focalLen?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_millimetres), it)) - } - metadata.iso?.let { - imageTakingConditions.add(String.format(getString(R.string.image_preview_unit_iso), it)) - } - - if (imageTakingConditions.isNotEmpty() && makeModel != null) { - binding.imgTCMakeModel.text = makeModel - binding.imgTCConditions.text = imageTakingConditions.joinToString(separator = TEXT_SEP) - binding.imgTC.visibility = View.VISIBLE - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun initMap(latitude: Double, longitude: Double, zoom: Double = 13.0) { - // required for OpenStreetMap - Configuration.getInstance().userAgentValue = MainApp.getUserAgent() - - val location = GeoPoint(latitude, longitude) - - binding.imageLocationMap.apply { - setTileSource(TileSourceFactory.MAPNIK) - - // set expected boundaries - setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) - isVerticalMapRepetitionEnabled = false - minZoomLevel = 2.0 - maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() - - // initial location - controller.setCenter(location) - controller.setZoom(zoom) - - // scale labels to be legible - isTilesScaledToDpi = true - setZoomRounding(true) - - // hide zoom buttons - zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) - - // enable multi-touch zoom - setMultiTouchControls(true) - setOnTouchListener { v, _ -> - v.parent.requestDisallowInterceptTouchEvent(true) - false - } - - val markerOverlay = ItemizedIconOverlay( - mutableListOf(OverlayItem(null, null, location)), - imagePinDrawable(context), - markerOnGestureListener(latitude, longitude), - context - ) - - overlays.add(markerOverlay) - - onResume() - } - - // add copyright notice - binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice - } - - @VisibleForTesting - fun hideMap() { - binding.imageLocationMap.visibility = View.GONE - } - - @SuppressLint("SimpleDateFormat") - private fun gatherMetadata() { - val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) - var timestamp = max(file.modificationTimestamp, file.creationTimestamp) - if (file.isDown) { - val exif = androidx.exifinterface.media.ExifInterface(file.storagePath) - var length = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_LENGTH)?.toInt() - var width = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_IMAGE_WIDTH)?.toInt() - var exposure = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_SHUTTER_SPEED_VALUE) - - // get timestamp from date string - exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_DATETIME)?.let { - timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp - } - - // format exposure string - if (exposure == null) { - exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_EXPOSURE_TIME)?.let { - exposure = "1/" + (1 / it.toDouble()).toInt() - } - } else if ("/" in exposure!!) { - try { - exposure!!.split("/").also { - exposure = "1/" + 2f.pow(it[0].toFloat() / it[1].toFloat()).roundToInt() - } - } catch (_: NumberFormatException) { - } - } - - // determine size if not contained in exif data - if ((width ?: 0) <= 0 || (length ?: 0) <= 0) { - val res = BitmapUtils.getImageResolution(file.storagePath) - width = res[0] - length = res[1] - } - - metadata = ImageMetadata( - fileSize = fileSize, - length = length, - width = width, - exposure = exposure, - date = formatDate(timestamp), - location = exif.latLong?.let { Pair(it[0], it[1]) }, - aperture = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_F_NUMBER), - focalLen = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), - make = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MAKE), - model = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_MODEL), - iso = exif.getAttribute(androidx.exifinterface.media.ExifInterface.TAG_ISO_SPEED) ?: exif.getAttribute( - androidx.exifinterface.media.ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY - ) - ) - } else { - // get metadata from server - val location = if (file.geoLocation == null) { - null - } else { - Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) - } - metadata = ImageMetadata( - fileSize = fileSize, - date = formatDate(timestamp), - location = location, - width = file.imageDimension?.width?.toInt(), - length = file.imageDimension?.height?.toInt() - ) - } - } - - @SuppressLint("SimpleDateFormat") - private fun formatDate(timestamp: Long): String = buildString { - append(SimpleDateFormat("EEEE").format(timestamp)) - append(TEXT_SEP) - append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) - append(TEXT_SEP) - append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) - } - - private fun imagePinDrawable(context: Context): LayerDrawable { - val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable - - val bitmap = - ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) - BitmapUtils.bitmapToCircularBitmapDrawable(resources, bitmap)?.let { - drawable.setDrawable(1, it) - } - - return drawable - } - - /** - * OnItemGestureListener for marker in MapView. - */ - private fun markerOnGestureListener(latitude: Double, longitude: Double) = - object : OnItemGestureListener { - override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { - val intent = Intent(Intent.ACTION_VIEW, "geo:0,0?q=$latitude,$longitude".toUri()) - DisplayUtils.startIntentIfAppAvailable(intent, activity, R.string.no_map_app_availble) - return true - } - - override fun onItemLongPress(index: Int, item: OverlayItem): Boolean = false - } - - @Parcelize - private data class ImageMetadata( - val fileSize: String? = null, - val date: String? = null, - val length: Int? = null, - val width: Int? = null, - val exposure: String? = null, - val aperture: String? = null, - val focalLen: String? = null, - val iso: String? = null, - val make: String? = null, - val model: String? = null, - val location: Pair? = null - ) : Parcelable - - companion object { - private const val ARG_FILE = "FILE" - private const val ARG_USER = "USER" - private const val ARG_METADATA = "METADATA" - private const val TEXT_SEP = " • " - private const val SCROLL_LIMIT = 80.0 - - @JvmStatic - fun newInstance(file: OCFile, user: User): ImageDetailFragment = ImageDetailFragment().apply { - arguments = Bundle().apply { - putParcelable(ARG_FILE, file) - putParcelable(ARG_USER, user) - } - } - } -} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt new file mode 100644 index 000000000000..3eddf0c3e0ac --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.core.os.BundleCompat +import androidx.fragment.app.Fragment +import com.nextcloud.client.account.User +import com.nextcloud.client.di.Injectable +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.MimeTypeUtil +import com.owncloud.android.utils.theme.ViewThemeUtils +import javax.inject.Inject + +class FileInfoFragment : + Fragment(), + Injectable { + private lateinit var binding: FileInfoFragmentBinding + + private val file: OCFile? by lazy { + arguments?.let { BundleCompat.getParcelable(it, ARG_FILE, OCFile::class.java) } + } + + private val user: User? by lazy { + arguments?.let { BundleCompat.getParcelable(it, ARG_USER, User::class.java) } + } + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) + + if (MimeTypeUtil.isImage(file)) { + val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) + file?.let { imageDetailInfo.init(it, binding) } + } + + val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) + governanceDetailInfo.init() + + return binding.root + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.run { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + } + } + + @VisibleForTesting + fun hideMap() { + binding.imageLocationMap.visibility = View.GONE + } + + companion object { + private const val ARG_FILE = "FILE" + private const val ARG_USER = "USER" + + fun newInstance(file: OCFile, user: User): FileInfoFragment = FileInfoFragment().apply { + arguments = Bundle().apply { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt new file mode 100644 index 000000000000..d951c0f55ab5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -0,0 +1,99 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.core.content.ContextCompat +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.material.textfield.TextInputLayout +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.ui.fileInfo.model.GovernanceLabel +import com.owncloud.android.R +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.utils.theme.ViewThemeUtils + +class GovernanceDetailInfo( + private val binding: FileInfoFragmentBinding, + private val viewThemeUtils: ViewThemeUtils, + private val fragment: FileInfoFragment +) { + private val context get() = fragment.requireContext() + + fun init() { + viewThemeUtils.material.themeCardView(binding.governanceLayout) + initSensitivityLabel() + initFileRetentionLabel() + } + + private fun initSensitivityLabel() { + initDropdown( + textInputLayout = binding.sensitivityLabel, + autoComplete = binding.sensitivityLabelAutoComplete, + items = listOf( + GovernanceLabel("Sharing restricted", R.drawable.ic_share), + GovernanceLabel("Download restricted", R.drawable.ic_download_grey600), + GovernanceLabel("Upload restricted", R.drawable.uploads) + ) + ) + } + + private fun initFileRetentionLabel() { + initDropdown( + textInputLayout = binding.fileRetentionLabel, + autoComplete = binding.fileRetentionAutoComplete, + items = listOf( + GovernanceLabel("Public", R.drawable.file_link), + GovernanceLabel("Internal use only", R.drawable.ic_group), + GovernanceLabel("Restricted", R.drawable.ic_cancel) + ) + ) + } + + private fun initDropdown( + textInputLayout: TextInputLayout, + autoComplete: MaterialAutoCompleteTextView, + items: List + ) { + viewThemeUtils.material.colorTextInputLayout(textInputLayout) + viewThemeUtils.files.themeAutoCompleteTextView(autoComplete) + + autoComplete.setAdapter(buildAdapter(items)) + + items.firstOrNull()?.let { applySelection(autoComplete, it) } + + autoComplete.setOnItemClickListener { _, _, position, _ -> + applySelection(autoComplete, items[position]) + } + } + + private fun buildAdapter(items: List) = + object : ArrayAdapter(context, R.layout.item_dropdown_with_icon, items) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val view = super.getView(position, convertView, parent) as TextView + getItem(position)?.let { item -> + view.text = item.text + view.setCompoundDrawablesWithIntrinsicBounds(tintedDrawable(item), null, null, null) + } + return view + } + } + + private fun applySelection(autoComplete: MaterialAutoCompleteTextView, item: GovernanceLabel) { + autoComplete.setText(item.text, false) + autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(tintedDrawable(item), null, null, null) + autoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + private fun tintedDrawable(item: GovernanceLabel) = + ContextCompat.getDrawable(context, item.iconRes)?.mutate()?.also { + viewThemeUtils.platform.tintDrawable(context, it, ColorRole.ON_SURFACE) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt new file mode 100644 index 000000000000..00baceba14d9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -0,0 +1,280 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.drawable.LayerDrawable +import android.view.View +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import androidx.exifinterface.media.ExifInterface +import androidx.lifecycle.lifecycleScope +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.NominatimClient +import com.nextcloud.ui.fileInfo.model.ImageMetadata +import com.owncloud.android.MainApp +import com.owncloud.android.R +import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.ThumbnailsCacheManager +import com.owncloud.android.utils.BitmapUtils +import com.owncloud.android.utils.DisplayUtils +import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.osmdroid.config.Configuration +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.ItemizedIconOverlay +import org.osmdroid.views.overlay.OverlayItem +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Locale +import kotlin.math.pow +import kotlin.math.roundToInt + +class ImageDetailInfo(private val fragment: FileInfoFragment, private val viewThemeUtils: ViewThemeUtils) { + + companion object { + private const val TEXT_SEP = " • " + private const val SCROLL_LIMIT = 80.0 + } + + fun init(file: OCFile, binding: FileInfoFragmentBinding) { + viewThemeUtils.material.themeCardView(binding.imageDetailLayout) + binding.imageDetailLayout.visibility = View.VISIBLE + + val metadata = gatherMetadata(file) + binding.fileInformationTime.text = metadata.date + binding.fileDetailsIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + fragment.requireContext(), + R.drawable.outline_image_24, + ColorRole.ON_BACKGROUND + ) + ) + + binding.cameraInformationIcon.setImageDrawable( + viewThemeUtils.platform.tintDrawable( + fragment.requireContext(), + R.drawable.outline_camera_24, + ColorRole.ON_BACKGROUND + ) + ) + + val fileInformation = buildList { + val length = metadata.length ?: 0 + val width = metadata.width ?: 0 + if (length > 0 && width > 0) { + runCatching { + @Suppress("MagicNumber") + val pxlCount = when (val res = length * width.toLong()) { + in 0..999_999 -> "%.2f".format(res / 1_000_000f) + in 1_000_000..9_999_999 -> "%.1f".format(res / 1_000_000f) + else -> (res / 1_000_000).toString() + } + add(fragment.getString(R.string.image_preview_unit_megapixel).format(pxlCount)) + add("$width × $length") + } + } + metadata.fileSize?.let { add(it) } + } + + if (fileInformation.isNotEmpty()) { + binding.fileInformationDetails.text = fileInformation.joinToString(TEXT_SEP) + binding.fileInformation.visibility = View.VISIBLE + } + + setImageTakenConditions(metadata, binding) + + metadata.location?.let { (lat, lon) -> + initMap(binding, file, lat, lon) + binding.imageLocation.visibility = View.VISIBLE + + fragment.lifecycleScope.launch(Dispatchers.IO) { + val nominatimClient = NominatimClient( + fragment.getString(R.string.osm_geocoder_url), + fragment.getString(R.string.osm_geocoder_contact) + ) + nominatimClient.reverseGeocode(lat, lon)?.let { result -> + withContext(Dispatchers.Main) { + binding.imageLocationText.text = result.displayName + binding.imageLocationText.visibility = View.VISIBLE + } + } + } + } + } + + private fun setImageTakenConditions(metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + val makeModel = when { + metadata.make == null -> metadata.model + metadata.model?.contains(metadata.make) == true -> metadata.model + else -> fragment.getString(R.string.make_model).format(metadata.make, metadata.model) + } + + val imageTakingConditions = buildList { + metadata.aperture?.let { add(fragment.getString(R.string.image_preview_unit_fnumber).format(it)) } + metadata.exposure?.let { add(fragment.getString(R.string.image_preview_unit_seconds).format(it)) } + metadata.focalLen?.let { add(fragment.getString(R.string.image_preview_unit_millimetres).format(it)) } + metadata.iso?.let { add(fragment.getString(R.string.image_preview_unit_iso).format(it)) } + } + + if (imageTakingConditions.isNotEmpty() && makeModel != null) { + binding.imgTCMakeModel.text = makeModel + binding.imgTCConditions.text = imageTakingConditions.joinToString(TEXT_SEP) + binding.imgTC.visibility = View.VISIBLE + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun initMap( + binding: FileInfoFragmentBinding, + file: OCFile, + latitude: Double, + longitude: Double, + zoom: Double = 13.0 + ) { + Configuration.getInstance().userAgentValue = MainApp.getUserAgent() + + val location = GeoPoint(latitude, longitude) + + binding.imageLocationMap.apply { + setTileSource(TileSourceFactory.MAPNIK) + setScrollableAreaLimitLatitude(SCROLL_LIMIT, -SCROLL_LIMIT, 0) + isVerticalMapRepetitionEnabled = false + minZoomLevel = 2.0 + maxZoomLevel = NominatimClient.Companion.ZoomLevel.MAX.int.toDouble() + controller.setCenter(location) + controller.setZoom(zoom) + isTilesScaledToDpi = true + setZoomRounding(true) + zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + setMultiTouchControls(true) + setOnTouchListener { v, _ -> + v.parent.requestDisallowInterceptTouchEvent(true) + false + } + overlays.add( + ItemizedIconOverlay( + mutableListOf(OverlayItem(null, null, location)), + imagePinDrawable(context, file), + markerOnGestureListener(latitude, longitude), + context + ) + ) + onResume() + } + + binding.imageLocationMapCopyright.text = + binding.imageLocationMap.tileProvider.tileSource.copyrightNotice + } + + fun gatherMetadata(file: OCFile): ImageMetadata { + val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) + val timestamp = maxOf(file.modificationTimestamp, file.creationTimestamp) + return if (file.isDown) { + gatherLocalMetadata(file, fileSize, timestamp) + } else { + gatherRemoteMetadata(file, fileSize, timestamp) + } + } + + private fun gatherLocalMetadata(file: OCFile, fileSize: String, fallbackTimestamp: Long): ImageMetadata { + val exif = ExifInterface(file.storagePath) + val timestamp = parseTimestamp(exif, fallbackTimestamp) + val (width, length) = parseImageDimensions(exif, file.storagePath) + + return ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + length = length, + width = width, + exposure = parseExposure(exif), + location = exif.latLong?.let { Pair(it[0], it[1]) }, + aperture = exif.getAttribute(ExifInterface.TAG_F_NUMBER), + focalLen = exif.getAttribute(ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM), + make = exif.getAttribute(ExifInterface.TAG_MAKE), + model = exif.getAttribute(ExifInterface.TAG_MODEL), + iso = exif.getAttribute(ExifInterface.TAG_ISO_SPEED) + ?: exif.getAttribute(ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY) + ) + } + + private fun gatherRemoteMetadata(file: OCFile, fileSize: String, timestamp: Long) = ImageMetadata( + fileSize = fileSize, + date = formatDate(timestamp), + location = file.geoLocation?.let { Pair(it.latitude, it.longitude) }, + width = file.imageDimension?.width?.toInt(), + length = file.imageDimension?.height?.toInt() + ) + + private fun parseTimestamp(exif: ExifInterface, fallback: Long): Long = + exif.getAttribute(ExifInterface.TAG_DATETIME)?.let { + SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time + } ?: fallback + + @Suppress("ReturnCount") + private fun parseExposure(exif: ExifInterface): String? { + val shutterSpeed = exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE) + val exposureTime = exif.getAttribute(ExifInterface.TAG_EXPOSURE_TIME) + ?.let { "1/${(1 / it.toDouble()).toInt()}" } + + val raw = shutterSpeed ?: exposureTime ?: return null + + if ('/' !in raw) return raw + + return runCatching { + raw.split("/").let { parts -> + "1/${2f.pow(parts[0].toFloat() / parts[1].toFloat()).roundToInt()}" + } + }.getOrDefault(raw) + } + + private fun parseImageDimensions(exif: ExifInterface, storagePath: String): Pair { + val width = exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.toInt() + val length = exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.toInt() + + if ((width ?: 0) > 0 && (length ?: 0) > 0) return Pair(width, length) + + return BitmapUtils.getImageResolution(storagePath).let { Pair(it[0], it[1]) } + } + + private fun formatDate(timestamp: Long): String = buildString { + append(SimpleDateFormat("EEEE", Locale.getDefault()).format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getDateInstance(DateFormat.MEDIUM).format(timestamp)) + append(TEXT_SEP) + append(DateFormat.getTimeInstance(DateFormat.SHORT).format(timestamp)) + } + + private fun imagePinDrawable(context: Context, file: OCFile): LayerDrawable = + (ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable).apply { + val bitmap = ThumbnailsCacheManager.getBitmapFromDiskCache( + ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId + ) + BitmapUtils.bitmapToCircularBitmapDrawable(fragment.resources, bitmap)?.let { + setDrawable(1, it) + } + } + + private fun markerOnGestureListener(latitude: Double, longitude: Double) = + object : ItemizedIconOverlay.OnItemGestureListener { + override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { + val intent = Intent(Intent.ACTION_VIEW, "geo:0,0?q=$latitude,$longitude".toUri()) + DisplayUtils.startIntentIfAppAvailable(intent, fragment.activity, R.string.no_map_app_availble) + return true + } + + override fun onItemLongPress(index: Int, item: OverlayItem) = false + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt new file mode 100644 index 000000000000..1904c790c40d --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt @@ -0,0 +1,10 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo.model + +data class GovernanceLabel(val text: String, val iconRes: Int) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt new file mode 100644 index 000000000000..ad57a9141f40 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt @@ -0,0 +1,26 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ImageMetadata( + val fileSize: String? = null, + val date: String? = null, + val length: Int? = null, + val width: Int? = null, + val exposure: String? = null, + val aperture: String? = null, + val focalLen: String? = null, + val iso: String? = null, + val make: String? = null, + val model: String? = null, + val location: Pair? = null +) : Parcelable diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java deleted file mode 100644 index 9f0f271e494c..000000000000 --- a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Nextcloud - Android Client - * - * SPDX-FileCopyrightText: 2018 Andy Scherzinger - * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only - */ -package com.owncloud.android.ui.adapter; - -import com.nextcloud.client.account.User; -import com.nextcloud.ui.ImageDetailFragment; -import com.owncloud.android.datamodel.OCFile; -import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment; -import com.owncloud.android.ui.fragment.FileDetailSharingFragment; -import com.owncloud.android.utils.MimeTypeUtil; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -/** - * File details pager adapter. - */ -public class FileDetailTabAdapter extends FragmentStateAdapter { - private final OCFile file; - private final User user; - private final boolean showSharingTab; - - private FileDetailSharingFragment fileDetailSharingFragment; - private FileDetailActivitiesFragment fileDetailActivitiesFragment; - private ImageDetailFragment imageDetailFragment; - - public FileDetailTabAdapter(FragmentActivity fragmentActivity, - OCFile file, - User user, - boolean showSharingTab) { - super(fragmentActivity); - this.file = file; - this.user = user; - this.showSharingTab = showSharingTab; - } - - public FileDetailSharingFragment getFileDetailSharingFragment() { - return fileDetailSharingFragment; - } - - public FileDetailActivitiesFragment getFileDetailActivitiesFragment() { - return fileDetailActivitiesFragment; - } - - public ImageDetailFragment getImageDetailFragment() { - return imageDetailFragment; - } - - @NonNull - @Override - public Fragment createFragment(int position) { - return switch (position) { - case 1 -> { - fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user); - yield fileDetailSharingFragment; - } - case 2 -> { - imageDetailFragment = ImageDetailFragment.newInstance(file, user); - yield imageDetailFragment; - } - default -> { - fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user); - yield fileDetailActivitiesFragment; - } - }; - } - - @Override - public int getItemCount() { - if (showSharingTab) { - if (MimeTypeUtil.isImage(file)) { - return 3; - } - return 2; - } else { - if (MimeTypeUtil.isImage(file)) { - return 2; - } - return 1; - } - } -} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt new file mode 100644 index 000000000000..9fbcba02eb8f --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt @@ -0,0 +1,48 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-FileCopyrightText: 2018 Andy Scherzinger + * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only + */ +package com.owncloud.android.ui.adapter + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.nextcloud.client.account.User +import com.nextcloud.ui.fileInfo.FileInfoFragment +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment +import com.owncloud.android.ui.fragment.FileDetailSharingFragment + +class FileDetailTabAdapter( + fragmentActivity: FragmentActivity, + private val file: OCFile, + private val user: User, + private val showSharingTab: Boolean +) : FragmentStateAdapter(fragmentActivity) { + + private enum class Tab(val position: Int) { + Activities(0), + Sharing(1), + Details(2) + } + + var fileDetailSharingFragment: FileDetailSharingFragment? = null + private set + var fileDetailActivitiesFragment: FileDetailActivitiesFragment? = null + private set + + override fun createFragment(position: Int): Fragment = when (position) { + Tab.Sharing.position -> FileDetailSharingFragment.newInstance(file, user) + .also { fileDetailSharingFragment = it } + + Tab.Details.position -> FileInfoFragment.newInstance(file, user) + + else -> FileDetailActivitiesFragment.newInstance(file, user) + .also { fileDetailActivitiesFragment = it } + } + + override fun getItemCount(): Int = if (showSharingTab) Tab.entries.size else Tab.entries.size - 1 +} diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java index 32ed83f9fa39..0f11b68b83f3 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java @@ -319,9 +319,7 @@ private void setupViewPager() { binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.selector_tab_share)); } - if (MimeTypeUtil.isImage(getFile())) { - binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_media)); - } + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_file_info)); viewThemeUtils.material.themeTabLayout(binding.tabLayout); @@ -388,7 +386,6 @@ public void onTabReselected(TabLayout.Tab tab) { @Override public void onSaveInstanceState(@NonNull Bundle outState) { super.onSaveInstanceState(outState); - FileExtensionsKt.logFileSize(getFile(), TAG); outState.putParcelable(ARG_FILE, getFile()); outState.putParcelable(ARG_USER, user); } diff --git a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt index c52e6c509773..ccf8152a8f8d 100644 --- a/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt +++ b/app/src/main/java/com/owncloud/android/utils/theme/FilesSpecificViewThemeUtils.kt @@ -28,6 +28,7 @@ import androidx.appcompat.widget.SearchView import androidx.core.content.res.ResourcesCompat import com.google.android.material.card.MaterialCardView import com.google.android.material.navigation.NavigationView +import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.nextcloud.android.common.ui.color.ColorUtil import com.nextcloud.android.common.ui.theme.MaterialSchemes import com.nextcloud.android.common.ui.theme.ViewThemeUtilsBase @@ -312,6 +313,16 @@ class FilesSpecificViewThemeUtils @Inject constructor( } } + fun themeAutoCompleteTextView(autoCompleteTextView: MaterialAutoCompleteTextView) { + withScheme(autoCompleteTextView) { scheme -> + autoCompleteTextView.setTextColor(dynamicColor.onSurface().getArgb(scheme)) + autoCompleteTextView.setHintTextColor(dynamicColor.onSurfaceVariant().getArgb(scheme)) + autoCompleteTextView.setDropDownBackgroundTintList( + ColorStateList.valueOf(dynamicColor.surfaceContainer().getArgb(scheme)) + ) + } + } + companion object { private val TAG = FilesSpecificViewThemeUtils::class.simpleName diff --git a/app/src/main/res/drawable/ic_dashboard_filled.xml b/app/src/main/res/drawable/ic_dashboard_filled.xml new file mode 100644 index 000000000000..111b1e97f44a --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_filled.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/ic_dashboard_outlined.xml b/app/src/main/res/drawable/ic_dashboard_outlined.xml new file mode 100644 index 000000000000..9d32ead03ef7 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_outlined.xml @@ -0,0 +1,15 @@ + + + + diff --git a/app/src/main/res/drawable/selector_file_info.xml b/app/src/main/res/drawable/selector_file_info.xml new file mode 100644 index 000000000000..71684759b904 --- /dev/null +++ b/app/src/main/res/drawable/selector_file_info.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml new file mode 100644 index 000000000000..3ae7570c8462 --- /dev/null +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_dropdown_with_icon.xml b/app/src/main/res/layout/item_dropdown_with_icon.xml new file mode 100644 index 000000000000..ac31e3f898fd --- /dev/null +++ b/app/src/main/res/layout/item_dropdown_with_icon.xml @@ -0,0 +1,14 @@ + + + diff --git a/app/src/main/res/layout/preview_image_details_fragment.xml b/app/src/main/res/layout/preview_image_details_fragment.xml deleted file mode 100644 index 72f0017e2ac5..000000000000 --- a/app/src/main/res/layout/preview_image_details_fragment.xml +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 516c83ee4ca8..aee39fda9c48 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -39,7 +39,7 @@ 100dp 100dp 4dp - + 300dp 14dp 12sp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f763f4f7973d..0bb61387bec3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,11 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> + Sensitivity label + File retention + Governance + Image details + Failed to open text editor All files Favorites