From 97e7113f2dd8ba11df75e585e0ab03ccf01d8a52 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Thu, 30 Apr 2026 16:13:53 +0200 Subject: [PATCH 01/15] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/ui/ImageDetailFragment.kt | 48 ++++++++++++++++++- .../ui/fragment/FileDetailFragment.java | 5 +- .../res/layout/item_dropdown_with_icon.xml | 9 ++++ .../layout/preview_image_details_fragment.xml | 16 +++++++ 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 app/src/main/res/layout/item_dropdown_with_icon.xml diff --git a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt index 766809e8247c..c59590ee5c63 100644 --- a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt @@ -16,6 +16,7 @@ import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ArrayAdapter import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.net.toUri @@ -72,6 +73,51 @@ class ImageDetailFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = PreviewImageDetailsFragmentBinding.inflate(layoutInflater, container, false) + // TODO Bottom sheet or dropdown? + data class DropdownItem(val text: String, val iconRes: Int) + val items = listOf( + DropdownItem("Option 1", R.drawable.outline_camera_24), + DropdownItem("Option 2", R.drawable.outline_image_24), + DropdownItem("Option 3", R.drawable.ic_information_outline) + ) + + val adapter = object : ArrayAdapter(requireContext(), 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 android.widget.TextView + val item = getItem(position) + if (item != null) { + view.text = item.text + val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + } + return view + } + } + binding.dropdownMenuAutoComplete.setAdapter(adapter) + + val defaultSelectedItem = items.firstOrNull() + if (defaultSelectedItem != null) { + binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + val selected = items[position] + binding.dropdownMenuAutoComplete.setText(selected.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.fileDetailsIcon.setImageDrawable( viewThemeUtils.platform.tintDrawable( requireContext(), @@ -395,4 +441,4 @@ class ImageDetailFragment : } } } -} +} \ No newline at end of file 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..dfb1f82b3471 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,8 @@ private void setupViewPager() { binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.selector_tab_share)); } - if (MimeTypeUtil.isImage(getFile())) { - binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_media)); - } + // TODO - Change detail tab icon? + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_media)); viewThemeUtils.material.themeTabLayout(binding.tabLayout); 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..e9324acddd27 --- /dev/null +++ b/app/src/main/res/layout/item_dropdown_with_icon.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/preview_image_details_fragment.xml b/app/src/main/res/layout/preview_image_details_fragment.xml index 72f0017e2ac5..d49741a3f741 100644 --- a/app/src/main/res/layout/preview_image_details_fragment.xml +++ b/app/src/main/res/layout/preview_image_details_fragment.xml @@ -17,6 +17,22 @@ android:orientation="vertical" android:padding="16dp"> + + + + + + Date: Tue, 5 May 2026 09:01:51 +0200 Subject: [PATCH 02/15] Rename .java to .kt Signed-off-by: alperozturk96 --- .../{FileDetailTabAdapter.java => FileDetailTabAdapter.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/owncloud/android/ui/adapter/{FileDetailTabAdapter.java => FileDetailTabAdapter.kt} (100%) 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.kt similarity index 100% rename from app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.java rename to app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt From f49b8de190889a444f8158299b24f72e96b8f58e Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:01:52 +0200 Subject: [PATCH 03/15] introduce file info fragment, separate image detail logic Signed-off-by: alperozturk96 --- .../FileDetailFragmentStaticServerIT.kt | 4 +- .../nextcloud/client/di/ComponentsModule.java | 4 +- .../com/nextcloud/ui/ImageDetailFragment.kt | 444 ------------------ .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 168 +++++++ .../nextcloud/ui/fileInfo/ImageDetailInfo.kt | 294 ++++++++++++ .../ui/fileInfo/model/ImageMetadata.kt | 26 + .../ui/adapter/FileDetailTabAdapter.kt | 113 ++--- .../ui/fragment/FileDetailFragment.java | 4 +- .../main/res/drawable/ic_dashboard_filled.xml | 24 + .../res/drawable/ic_dashboard_outlined.xml | 24 + .../main/res/drawable/selector_file_info.xml | 11 + ...ls_fragment.xml => file_info_fragment.xml} | 0 12 files changed, 592 insertions(+), 524 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/model/ImageMetadata.kt create mode 100644 app/src/main/res/drawable/ic_dashboard_filled.xml create mode 100644 app/src/main/res/drawable/ic_dashboard_outlined.xml create mode 100644 app/src/main/res/drawable/selector_file_info.xml rename app/src/main/res/layout/{preview_image_details_fragment.xml => file_info_fragment.xml} (100%) 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 c59590ee5c63..000000000000 --- a/app/src/main/java/com/nextcloud/ui/ImageDetailFragment.kt +++ /dev/null @@ -1,444 +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 android.widget.ArrayAdapter -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) - - // TODO Bottom sheet or dropdown? - data class DropdownItem(val text: String, val iconRes: Int) - val items = listOf( - DropdownItem("Option 1", R.drawable.outline_camera_24), - DropdownItem("Option 2", R.drawable.outline_image_24), - DropdownItem("Option 3", R.drawable.ic_information_outline) - ) - - val adapter = object : ArrayAdapter(requireContext(), 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 android.widget.TextView - val item = getItem(position) - if (item != null) { - view.text = item.text - val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) - } - return view - } - } - binding.dropdownMenuAutoComplete.setAdapter(adapter) - - val defaultSelectedItem = items.firstOrNull() - if (defaultSelectedItem != null) { - binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) - } - - binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> - val selected = items[position] - binding.dropdownMenuAutoComplete.setText(selected.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) - } - - - 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) - } - } - } -} \ No newline at end of file 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..2c45a2cf8d5f --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -0,0 +1,168 @@ +/* + * 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.fileInfo + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.client.account.User +import com.nextcloud.client.di.Injectable +import com.nextcloud.ui.fileInfo.model.ImageMetadata +import com.nextcloud.utils.extensions.getParcelableArgument +import com.owncloud.android.R +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 lateinit var file: OCFile + private lateinit var user: User + private var metadata: ImageMetadata? = null + private var imageDetailInfo: ImageDetailInfo? = null + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) + + 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)!! + } + + imageDetailInfo = ImageDetailInfo(this) + + + // TODO Bottom sheet or dropdown? + data class DropdownItem(val text: String, val iconRes: Int) + val items = listOf( + DropdownItem("Option 1", R.drawable.outline_camera_24), + DropdownItem("Option 2", R.drawable.outline_image_24), + DropdownItem("Option 3", R.drawable.ic_information_outline) + ) + + val adapter = object : ArrayAdapter(requireContext(), 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 + val item = getItem(position) + if (item != null) { + view.text = item.text + val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + } + return view + } + } + binding.dropdownMenuAutoComplete.setAdapter(adapter) + + val defaultSelectedItem = items.firstOrNull() + if (defaultSelectedItem != null) { + binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) + } + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + val selected = items[position] + binding.dropdownMenuAutoComplete.setText(selected.text, false) + val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + // + + 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 + ) + ) + + return binding.root + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.run { + putParcelable(ARG_FILE, file) + putParcelable(ARG_USER, user) + putParcelable(ARG_METADATA, metadata) + } + } + + override fun onStart() { + super.onStart() + + if (MimeTypeUtil.isImage(file)) { + metadata = imageDetailInfo?.gatherMetadata(file) + } + + setupFragment() + } + + @VisibleForTesting + fun hideMap() { + binding.imageLocationMap.visibility = View.GONE + } + + private fun setupFragment() { + if (MimeTypeUtil.isImage(file)) { + metadata?.let { imageDetailInfo?.init(file, it, binding) } + } + } + + companion object { + private const val ARG_FILE = "FILE" + private const val ARG_USER = "USER" + private const val ARG_METADATA = "METADATA" + + @JvmStatic + 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/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt new file mode 100644 index 000000000000..861223f3eda6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -0,0 +1,294 @@ +/* + * 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.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 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 +import kotlin.ranges.contains +import kotlin.text.contains +import kotlin.text.split + +class ImageDetailInfo(private val fragment: FileInfoFragment) { + companion object { + private const val TEXT_SEP = " • " + private const val SCROLL_LIMIT = 80.0 + } + + fun init(file: OCFile, metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + 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(fragment.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(metadata, binding) + + // initialise map and address views + metadata.location?.let { location -> + initMap(binding, file,location.first,location.second) + binding.imageLocation.visibility = View.VISIBLE + + // launch reverse geocoding request + fragment.lifecycleScope.launch(Dispatchers.IO) { + val nominatimClient = NominatimClient( + fragment.getString(R.string.osm_geocoder_url), + fragment.getString(R.string.osm_geocoder_contact) + ) + 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(metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + // 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( + fragment.getString(R.string.make_model), + metadata.make, + metadata.model + ) + } + + // image taking conditions + val imageTakingConditions = mutableListOf() + metadata.aperture?.let { + imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_fnumber), it)) + } + metadata.exposure?.let { + imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_seconds), it)) + } + metadata.focalLen?.let { + imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_millimetres), it)) + } + metadata.iso?.let { + imageTakingConditions.add(String.format(fragment.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(binding: FileInfoFragmentBinding, file: OCFile, 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, file), + markerOnGestureListener(latitude, longitude), + context + ) + + overlays.add(markerOverlay) + + onResume() + } + + // add copyright notice + binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice + } + + @SuppressLint("SimpleDateFormat") + fun gatherMetadata(file: OCFile): ImageMetadata { + val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) + var timestamp = java.lang.Long.max(file.modificationTimestamp, file.creationTimestamp) + return if (file.isDown) { + val exif = ExifInterface(file.storagePath) + var length = exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.toInt() + var width = exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.toInt() + var exposure = exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE) + + // get timestamp from date string + exif.getAttribute(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(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] + } + + ImageMetadata( + fileSize = fileSize, + length = length, + width = width, + exposure = exposure, + date = formatDate(timestamp), + 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 + ) + ) + } else { + // get metadata from server + val location = if (file.geoLocation == null) { + null + } else { + Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) + } + 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, file: OCFile): LayerDrawable { + val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable + + val bitmap = + ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) + BitmapUtils.bitmapToCircularBitmapDrawable(fragment.resources, bitmap)?.let { + drawable.setDrawable(1, it) + } + + return drawable + } + + /** + * OnItemGestureListener for marker in MapView. + */ + 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): Boolean = false + } +} 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.kt b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt index 9f0f271e494c..fa66d28e7f81 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt @@ -1,88 +1,55 @@ /* * 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 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; +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) { + var fileDetailSharingFragment: FileDetailSharingFragment? = null + private set + var fileDetailActivitiesFragment: FileDetailActivitiesFragment? = null + private set + + override fun createFragment(position: Int): Fragment { + return when (position) { + 1 -> { + fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user) + fileDetailSharingFragment } - case 2 -> { - imageDetailFragment = ImageDetailFragment.newInstance(file, user); - yield imageDetailFragment; + + 2 -> { + FileInfoFragment.newInstance(file, user) } - default -> { - fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user); - yield fileDetailActivitiesFragment; + + else -> { + fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user) + fileDetailActivitiesFragment } - }; + }!! } - @Override - public int getItemCount() { - if (showSharingTab) { - if (MimeTypeUtil.isImage(file)) { - return 3; - } - return 2; + override fun getItemCount(): Int { + return if (showSharingTab) { + 3 } else { - if (MimeTypeUtil.isImage(file)) { - return 2; - } - return 1; + 2 } } } 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 dfb1f82b3471..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,8 +319,7 @@ private void setupViewPager() { binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.selector_tab_share)); } - // TODO - Change detail tab icon? - 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); @@ -387,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/res/drawable/ic_dashboard_filled.xml b/app/src/main/res/drawable/ic_dashboard_filled.xml new file mode 100644 index 000000000000..2d5b26893695 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_filled.xml @@ -0,0 +1,24 @@ + + + + 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..49ba8ca94c99 --- /dev/null +++ b/app/src/main/res/drawable/ic_dashboard_outlined.xml @@ -0,0 +1,24 @@ + + + + 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/preview_image_details_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml similarity index 100% rename from app/src/main/res/layout/preview_image_details_fragment.xml rename to app/src/main/res/layout/file_info_fragment.xml From 7f9556e7e8ec4e32662fbbed36657e549d212189 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:27:36 +0200 Subject: [PATCH 04/15] simplify, split gov logic Signed-off-by: alperozturk96 --- .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 121 +------- .../ui/fileInfo/GovernanceDetailInfo.kt | 73 +++++ .../nextcloud/ui/fileInfo/ImageDetailInfo.kt | 291 +++++++++--------- .../ui/fileInfo/model/SensitivityLabel.kt | 10 + .../ui/adapter/FileDetailTabAdapter.kt | 39 +-- 5 files changed, 252 insertions(+), 282 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index 2c45a2cf8d5f..7a04aa727f98 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -1,9 +1,8 @@ /* * 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 + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later */ package com.nextcloud.ui.fileInfo @@ -12,17 +11,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ArrayAdapter -import android.widget.TextView import androidx.annotation.VisibleForTesting -import androidx.core.content.ContextCompat +import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment -import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.User import com.nextcloud.client.di.Injectable -import com.nextcloud.ui.fileInfo.model.ImageMetadata -import com.nextcloud.utils.extensions.getParcelableArgument -import com.owncloud.android.R import com.owncloud.android.databinding.FileInfoFragmentBinding import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.MimeTypeUtil @@ -33,10 +26,14 @@ class FileInfoFragment : Fragment(), Injectable { private lateinit var binding: FileInfoFragmentBinding - private lateinit var file: OCFile - private lateinit var user: User - private var metadata: ImageMetadata? = null - private var imageDetailInfo: ImageDetailInfo? = null + + 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 @@ -44,80 +41,13 @@ class FileInfoFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) - 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)!! - } - - imageDetailInfo = ImageDetailInfo(this) - - - // TODO Bottom sheet or dropdown? - data class DropdownItem(val text: String, val iconRes: Int) - val items = listOf( - DropdownItem("Option 1", R.drawable.outline_camera_24), - DropdownItem("Option 2", R.drawable.outline_image_24), - DropdownItem("Option 3", R.drawable.ic_information_outline) - ) - - val adapter = object : ArrayAdapter(requireContext(), 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 - val item = getItem(position) - if (item != null) { - view.text = item.text - val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) - } - return view - } - } - binding.dropdownMenuAutoComplete.setAdapter(adapter) - - val defaultSelectedItem = items.firstOrNull() - if (defaultSelectedItem != null) { - binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), defaultSelectedItem.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(requireContext(),it, ColorRole.ON_SURFACE) - } - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) - } - - binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> - val selected = items[position] - binding.dropdownMenuAutoComplete.setText(selected.text, false) - val drawable = ContextCompat.getDrawable(requireContext(), selected.iconRes)?.mutate() - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = resources.getDimensionPixelSize(R.dimen.standard_padding) + val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) + if (MimeTypeUtil.isImage(file)) { + file?.let { imageDetailInfo.init(it, binding) } } - // - - 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 governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) + governanceDetailInfo.init() return binding.root } @@ -127,18 +57,7 @@ class FileInfoFragment : outState.run { putParcelable(ARG_FILE, file) putParcelable(ARG_USER, user) - putParcelable(ARG_METADATA, metadata) - } - } - - override fun onStart() { - super.onStart() - - if (MimeTypeUtil.isImage(file)) { - metadata = imageDetailInfo?.gatherMetadata(file) } - - setupFragment() } @VisibleForTesting @@ -146,18 +65,10 @@ class FileInfoFragment : binding.imageLocationMap.visibility = View.GONE } - private fun setupFragment() { - if (MimeTypeUtil.isImage(file)) { - metadata?.let { imageDetailInfo?.init(file, it, binding) } - } - } - companion object { private const val ARG_FILE = "FILE" private const val ARG_USER = "USER" - private const val ARG_METADATA = "METADATA" - @JvmStatic fun newInstance(file: OCFile, user: User): FileInfoFragment = FileInfoFragment().apply { arguments = Bundle().apply { putParcelable(ARG_FILE, file) 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..a8dd7fb43b23 --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -0,0 +1,73 @@ +/* + * 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.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.ui.fileInfo.model.SensitivityLabel +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 +) { + + fun init() { + val items = listOf( + SensitivityLabel("Option 1", R.drawable.outline_camera_24), + SensitivityLabel("Option 2", R.drawable.outline_image_24), + SensitivityLabel("Option 3", R.drawable.ic_information_outline) + ) + + val adapter = object : + ArrayAdapter(fragment.requireContext(), 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 + val item = getItem(position) + if (item != null) { + view.text = item.text + val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) + } + view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + } + return view + } + } + binding.dropdownMenuAutoComplete.setAdapter(adapter) + + val defaultSelectedItem = items.firstOrNull() + if (defaultSelectedItem != null) { + binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + val drawable = ContextCompat.getDrawable(fragment.requireContext(), defaultSelectedItem.iconRes)?.mutate() + drawable?.let { + viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) + } + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = + fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } + + binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + val selected = items[position] + binding.dropdownMenuAutoComplete.setText(selected.text, false) + val drawable = ContextCompat.getDrawable(fragment.requireContext(), selected.iconRes)?.mutate() + binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.dropdownMenuAutoComplete.compoundDrawablePadding = + fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt index 861223f3eda6..2fbb72c2cf28 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -16,6 +16,7 @@ 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 @@ -25,6 +26,7 @@ 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 @@ -39,60 +41,71 @@ import java.text.SimpleDateFormat import java.util.Locale import kotlin.math.pow import kotlin.math.roundToInt -import kotlin.ranges.contains -import kotlin.text.contains -import kotlin.text.split -class ImageDetailInfo(private val fragment: FileInfoFragment) { +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, metadata: ImageMetadata, binding: FileInfoFragmentBinding) { + fun init(file: OCFile, binding: FileInfoFragmentBinding) { + 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 + ) + ) - // 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() + 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") } - - fileInformation.add(String.format(fragment.getString(R.string.image_preview_unit_megapixel), pxlCount)) - fileInformation.add("${metadata.width} × ${metadata.length}") - } catch (_: NumberFormatException) { } + metadata.fileSize?.let { add(it) } } - metadata.fileSize?.let { fileInformation.add(it) } if (fileInformation.isNotEmpty()) { - binding.fileInformationDetails.text = fileInformation.joinToString(separator = TEXT_SEP) + binding.fileInformationDetails.text = fileInformation.joinToString(TEXT_SEP) binding.fileInformation.visibility = View.VISIBLE } setImageTakenConditions(metadata, binding) - // initialise map and address views - metadata.location?.let { location -> - initMap(binding, file,location.first,location.second) + metadata.location?.let { (lat, lon) -> + initMap(binding, file, lat, lon) binding.imageLocation.visibility = View.VISIBLE - // launch reverse geocoding request fragment.lifecycleScope.launch(Dispatchers.IO) { val nominatimClient = NominatimClient( fragment.getString(R.string.osm_geocoder_url), fragment.getString(R.string.osm_geocoder_contact) ) - val geocodingResult = nominatimClient.reverseGeocode(location.first, location.second) - if (geocodingResult != null) { + nominatimClient.reverseGeocode(lat, lon)?.let { result -> withContext(Dispatchers.Main) { + binding.imageLocationText.text = result.displayName binding.imageLocationText.visibility = View.VISIBLE - binding.imageLocationText.text = geocodingResult.displayName } } } @@ -100,187 +113,157 @@ class ImageDetailInfo(private val fragment: FileInfoFragment) { } private fun setImageTakenConditions(metadata: ImageMetadata, binding: FileInfoFragmentBinding) { - // camera make and model - val makeModel = if (metadata.make?.let { metadata.model?.contains(it) } == false) { - "${metadata.make} ${metadata.model}" - } else { - metadata.model ?: metadata.make + 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) } - if (metadata.make == null || metadata.model?.contains(metadata.make) == true) { - binding.imgTCMakeModel.text = metadata.model - } else { - binding.imgTCMakeModel.text = String.format( - fragment.getString(R.string.make_model), - metadata.make, - metadata.model - ) - } - - // image taking conditions - val imageTakingConditions = mutableListOf() - metadata.aperture?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_fnumber), it)) - } - metadata.exposure?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_seconds), it)) - } - metadata.focalLen?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_millimetres), it)) - } - metadata.iso?.let { - imageTakingConditions.add(String.format(fragment.getString(R.string.image_preview_unit_iso), it)) + 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(separator = TEXT_SEP) + 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) { - // required for OpenStreetMap + 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) - - // 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, file), - markerOnGestureListener(latitude, longitude), - context + overlays.add( + ItemizedIconOverlay( + mutableListOf(OverlayItem(null, null, location)), + imagePinDrawable(context, file), + markerOnGestureListener(latitude, longitude), + context + ) ) - - overlays.add(markerOverlay) - onResume() } - // add copyright notice - binding.imageLocationMapCopyright.text = binding.imageLocationMap.tileProvider.tileSource.copyrightNotice + binding.imageLocationMapCopyright.text = + binding.imageLocationMap.tileProvider.tileSource.copyrightNotice } - @SuppressLint("SimpleDateFormat") fun gatherMetadata(file: OCFile): ImageMetadata { val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) - var timestamp = java.lang.Long.max(file.modificationTimestamp, file.creationTimestamp) + val timestamp = maxOf(file.modificationTimestamp, file.creationTimestamp) return if (file.isDown) { - val exif = ExifInterface(file.storagePath) - var length = exif.getAttribute(ExifInterface.TAG_IMAGE_LENGTH)?.toInt() - var width = exif.getAttribute(ExifInterface.TAG_IMAGE_WIDTH)?.toInt() - var exposure = exif.getAttribute(ExifInterface.TAG_SHUTTER_SPEED_VALUE) - - // get timestamp from date string - exif.getAttribute(ExifInterface.TAG_DATETIME)?.let { - timestamp = SimpleDateFormat("y:M:d H:m:s", Locale.ROOT).parse(it)?.time ?: timestamp - } + gatherLocalMetadata(file, fileSize, timestamp) + } else { + gatherRemoteMetadata(file, fileSize, timestamp) + } + } - // format exposure string - if (exposure == null) { - exif.getAttribute(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) { - } - } + 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) + ) + } - // 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] - } + 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() + ) - ImageMetadata( - fileSize = fileSize, - length = length, - width = width, - exposure = exposure, - date = formatDate(timestamp), - 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 - ) - ) - } else { - // get metadata from server - val location = if (file.geoLocation == null) { - null - } else { - Pair(file.geoLocation!!.latitude, file.geoLocation!!.longitude) + 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()}" } - ImageMetadata( - fileSize = fileSize, - date = formatDate(timestamp), - location = location, - width = file.imageDimension?.width?.toInt(), - length = file.imageDimension?.height?.toInt() - ) - } + }.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]) } } - @SuppressLint("SimpleDateFormat") private fun formatDate(timestamp: Long): String = buildString { - append(SimpleDateFormat("EEEE").format(timestamp)) + 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 { - val drawable = ContextCompat.getDrawable(context, R.drawable.photo_pin) as LayerDrawable - - val bitmap = - ThumbnailsCacheManager.getBitmapFromDiskCache(ThumbnailsCacheManager.PREFIX_THUMBNAIL + file.remoteId) - BitmapUtils.bitmapToCircularBitmapDrawable(fragment.resources, bitmap)?.let { - drawable.setDrawable(1, it) + 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) + } } - return drawable - } - - /** - * OnItemGestureListener for marker in MapView. - */ private fun markerOnGestureListener(latitude: Double, longitude: Double) = object : ItemizedIconOverlay.OnItemGestureListener { override fun onItemSingleTapUp(index: Int, item: OverlayItem): Boolean { @@ -289,6 +272,6 @@ class ImageDetailInfo(private val fragment: FileInfoFragment) { return true } - override fun onItemLongPress(index: Int, item: OverlayItem): Boolean = false + override fun onItemLongPress(index: Int, item: OverlayItem) = false } } diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt new file mode 100644 index 000000000000..d9895ee6fe3a --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.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 SensitivityLabel(val text: String, val iconRes: Int) 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 index fa66d28e7f81..9fbcba02eb8f 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt @@ -22,34 +22,27 @@ class FileDetailTabAdapter( 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 { - return when (position) { - 1 -> { - fileDetailSharingFragment = FileDetailSharingFragment.newInstance(file, user) - fileDetailSharingFragment - } - - 2 -> { - FileInfoFragment.newInstance(file, user) - } - - else -> { - fileDetailActivitiesFragment = FileDetailActivitiesFragment.newInstance(file, user) - fileDetailActivitiesFragment - } - }!! - } + 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) - override fun getItemCount(): Int { - return if (showSharingTab) { - 3 - } else { - 2 - } + else -> FileDetailActivitiesFragment.newInstance(file, user) + .also { fileDetailActivitiesFragment = it } } + + override fun getItemCount(): Int = if (showSharingTab) Tab.entries.size else Tab.entries.size - 1 } From e509f755a5d92c142c8badd14abc582104e5d749 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:28:27 +0200 Subject: [PATCH 05/15] simplify, split gov logic Signed-off-by: alperozturk96 --- app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index 7a04aa727f98..3eddf0c3e0ac 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -41,8 +41,8 @@ class FileInfoFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) - val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) if (MimeTypeUtil.isImage(file)) { + val imageDetailInfo = ImageDetailInfo(this, viewThemeUtils) file?.let { imageDetailInfo.init(it, binding) } } From 2d1ead50a381c3f24fe9cae0d0ced7f3a23c45bb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 09:55:22 +0200 Subject: [PATCH 06/15] split ui, better margins Signed-off-by: alperozturk96 --- .../ui/fileInfo/GovernanceDetailInfo.kt | 16 +- .../main/res/layout/file_info_fragment.xml | 260 ++++++++++-------- app/src/main/res/values/dims.xml | 2 +- app/src/main/res/values/strings.xml | 3 + 4 files changed, 155 insertions(+), 126 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index a8dd7fb43b23..90c2a1e35be2 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -47,26 +47,26 @@ class GovernanceDetailInfo( return view } } - binding.dropdownMenuAutoComplete.setAdapter(adapter) + binding.sensitivityLabelAutoComplete.setAdapter(adapter) val defaultSelectedItem = items.firstOrNull() if (defaultSelectedItem != null) { - binding.dropdownMenuAutoComplete.setText(defaultSelectedItem.text, false) + binding.sensitivityLabelAutoComplete.setText(defaultSelectedItem.text, false) val drawable = ContextCompat.getDrawable(fragment.requireContext(), defaultSelectedItem.iconRes)?.mutate() drawable?.let { viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) } - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = + binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.sensitivityLabelAutoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) } - binding.dropdownMenuAutoComplete.setOnItemClickListener { _, _, position, _ -> + binding.sensitivityLabelAutoComplete.setOnItemClickListener { _, _, position, _ -> val selected = items[position] - binding.dropdownMenuAutoComplete.setText(selected.text, false) + binding.sensitivityLabelAutoComplete.setText(selected.text, false) val drawable = ContextCompat.getDrawable(fragment.requireContext(), selected.iconRes)?.mutate() - binding.dropdownMenuAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.dropdownMenuAutoComplete.compoundDrawablePadding = + binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) + binding.sensitivityLabelAutoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) } } diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml index d49741a3f741..d70d31676595 100644 --- a/app/src/main/res/layout/file_info_fragment.xml +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -15,164 +15,190 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="16dp"> + android:padding="@dimen/standard_padding"> - + android:orientation="vertical"> + + + + + + - + android:layout_marginBottom="16dp" + android:hint="@string/governance_file_detention_label"> + + + + + + - + android:orientation="vertical"> + android:orientation="vertical"> + + - - android:src="@drawable/outline_image_24" /> + + + + + + + + android:gravity="center_vertical" + android:orientation="horizontal" + android:visibility="gone" + tools:visibility="visible"> - + - + android:orientation="vertical"> + + + + + + - - - + android:layout_width="match_parent" + android:layout_height="@dimen/image_detail_map_height" + android:background="@drawable/rounded_rect" + android:elevation="1dp" + android:orientation="vertical"> - - - - - - - - + android:visibility="gone" + tools:text="@string/placeholder_image_detail_location" + tools:visibility="visible" /> - - - - - - - + android:layout_height="match_parent"> + + + + + - - + - + - + + 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..c0d053118817 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,6 +6,9 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only --> + Sensitivity label + File detention + Failed to open text editor All files Favorites From 21497e66972f5589e36bfe20b8b8e0672e7db209 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 10:08:27 +0200 Subject: [PATCH 07/15] use m3 card to distinc between sections Signed-off-by: alperozturk96 --- .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 1 + .../ui/fileInfo/GovernanceDetailInfo.kt | 5 + .../nextcloud/ui/fileInfo/ImageDetailInfo.kt | 3 + .../main/res/layout/file_info_fragment.xml | 171 ++++++++++++------ app/src/main/res/values/strings.xml | 2 + 5 files changed, 122 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index 3eddf0c3e0ac..c9d24a608348 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -46,6 +46,7 @@ class FileInfoFragment : file?.let { imageDetailInfo.init(it, binding) } } + val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) governanceDetailInfo.init() diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index 90c2a1e35be2..ab2203252530 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -25,6 +25,8 @@ class GovernanceDetailInfo( ) { fun init() { + viewThemeUtils.material.themeCardView(binding.governanceLayout) + val items = listOf( SensitivityLabel("Option 1", R.drawable.outline_camera_24), SensitivityLabel("Option 2", R.drawable.outline_image_24), @@ -69,5 +71,8 @@ class GovernanceDetailInfo( binding.sensitivityLabelAutoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) } + + viewThemeUtils.material.colorTextInputLayout(binding.sensitivityLabel) + viewThemeUtils.material.colorTextInputLayout(binding.fileDetentionLabel) } } diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt index 2fbb72c2cf28..00baceba14d9 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -50,6 +50,9 @@ class ImageDetailInfo(private val fragment: FileInfoFragment, private val viewTh } 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( diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml index d70d31676595..8b746716a2db 100644 --- a/app/src/main/res/layout/file_info_fragment.xml +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -2,11 +2,13 @@ @@ -17,58 +19,91 @@ android:orientation="vertical" android:padding="@dimen/standard_padding"> - + android:layout_marginBottom="@dimen/standard_margin" + app:cardElevation="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" + app:cardCornerRadius="@dimen/card_corner_radius" + app:cardBackgroundColor="?attr/colorSurface"> - + android:orientation="vertical" + android:padding="@dimen/standard_padding"> - + + + android:layout_marginBottom="@dimen/standard_margin" + android:hint="@string/governance_sensitivity_label"> - + - + - + android:hint="@string/governance_file_detention_label"> - + - + + + + - + app:cardElevation="0dp" + app:strokeColor="?attr/colorOutline" + app:strokeWidth="1dp" + app:cardCornerRadius="@dimen/card_corner_radius" + app:cardBackgroundColor="?attr/colorSurface" + android:visibility="gone" + tools:visibility="visible"> + android:orientation="vertical" + android:padding="@dimen/standard_padding"> + + @@ -103,6 +139,8 @@ android:id="@+id/fileInformation_details" android:layout_width="match_parent" android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Material3.BodySmall" + android:textColor="?attr/colorOnSurfaceVariant" tools:text="@string/placeholder_image_details" /> @@ -111,6 +149,7 @@ android:id="@+id/imgTC" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginTop="@dimen/standard_quarter_margin" android:gravity="center_vertical" android:orientation="horizontal" android:visibility="gone" @@ -133,6 +172,7 @@ android:id="@+id/imgTC_makeModel" android:layout_width="match_parent" android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Material3.BodyMedium" android:textStyle="bold" tools:text="@string/placeholder_image_detail_model" /> @@ -140,26 +180,25 @@ android:id="@+id/imgTC_conditions" android:layout_width="match_parent" android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Material3.BodySmall" + android:textColor="?attr/colorOnSurfaceVariant" tools:text="@string/placeholder_image_detail_condition" /> - - - - + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/standard_margin" + android:orientation="vertical" + android:visibility="gone" + tools:visibility="visible"> + + - + android:layout_height="@dimen/image_detail_map_height" + app:cardElevation="0dp" + app:strokeColor="?attr/colorOutlineVariant" + app:strokeWidth="1dp" + app:cardCornerRadius="@dimen/standard_half_padding"> - - - - + android:layout_height="match_parent"> + + + + + + + - - + - + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0d053118817..8a00de5822ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,6 +8,8 @@ Sensitivity label File detention + Governance + Image details Failed to open text editor All files From 81690f87c83c6661f1d845bef638827cc8584166 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 10:21:01 +0200 Subject: [PATCH 08/15] simplify Signed-off-by: alperozturk96 --- .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 1 - .../ui/fileInfo/GovernanceDetailInfo.kt | 95 +++++++++++-------- ...SensitivityLabel.kt => GovernanceLabel.kt} | 2 +- .../theme/FilesSpecificViewThemeUtils.kt | 11 +++ 4 files changed, 70 insertions(+), 39 deletions(-) rename app/src/main/java/com/nextcloud/ui/fileInfo/model/{SensitivityLabel.kt => GovernanceLabel.kt} (75%) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index c9d24a608348..3eddf0c3e0ac 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -46,7 +46,6 @@ class FileInfoFragment : file?.let { imageDetailInfo.init(it, binding) } } - val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) governanceDetailInfo.init() diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index ab2203252530..a1cfad20f3ee 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -12,8 +12,10 @@ 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.SensitivityLabel +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 @@ -23,56 +25,75 @@ class GovernanceDetailInfo( private val viewThemeUtils: ViewThemeUtils, private val fragment: FileInfoFragment ) { + private val context get() = fragment.requireContext() fun init() { viewThemeUtils.material.themeCardView(binding.governanceLayout) + initSensitivityLabel() + initFileDetentionLabel() + } + + 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) + ) + ) + } - val items = listOf( - SensitivityLabel("Option 1", R.drawable.outline_camera_24), - SensitivityLabel("Option 2", R.drawable.outline_image_24), - SensitivityLabel("Option 3", R.drawable.ic_information_outline) + private fun initFileDetentionLabel() { + initDropdown( + textInputLayout = binding.fileDetentionLabel, + autoComplete = binding.fileDetentionAutoComplete, + 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) } - val adapter = object : - ArrayAdapter(fragment.requireContext(), R.layout.item_dropdown_with_icon, items) { + 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 - val item = getItem(position) - if (item != null) { + getItem(position)?.let { item -> view.text = item.text - val drawable = ContextCompat.getDrawable(context, item.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) - } - view.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) + view.setCompoundDrawablesWithIntrinsicBounds(tintedDrawable(item), null, null, null) } return view } } - binding.sensitivityLabelAutoComplete.setAdapter(adapter) - val defaultSelectedItem = items.firstOrNull() - if (defaultSelectedItem != null) { - binding.sensitivityLabelAutoComplete.setText(defaultSelectedItem.text, false) - val drawable = ContextCompat.getDrawable(fragment.requireContext(), defaultSelectedItem.iconRes)?.mutate() - drawable?.let { - viewThemeUtils.platform.tintDrawable(fragment.requireContext(), it, ColorRole.ON_SURFACE) - } - binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.sensitivityLabelAutoComplete.compoundDrawablePadding = - fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) - } + 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) + } - binding.sensitivityLabelAutoComplete.setOnItemClickListener { _, _, position, _ -> - val selected = items[position] - binding.sensitivityLabelAutoComplete.setText(selected.text, false) - val drawable = ContextCompat.getDrawable(fragment.requireContext(), selected.iconRes)?.mutate() - binding.sensitivityLabelAutoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(drawable, null, null, null) - binding.sensitivityLabelAutoComplete.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) } - - viewThemeUtils.material.colorTextInputLayout(binding.sensitivityLabel) - viewThemeUtils.material.colorTextInputLayout(binding.fileDetentionLabel) - } } diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt similarity index 75% rename from app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt rename to app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt index d9895ee6fe3a..1904c790c40d 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/model/SensitivityLabel.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt @@ -7,4 +7,4 @@ package com.nextcloud.ui.fileInfo.model -data class SensitivityLabel(val text: String, val iconRes: Int) +data class GovernanceLabel(val text: String, val iconRes: Int) 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 From 43b91a7f7819ec5bf42409d37108f3cbaaa9db87 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 10:35:20 +0200 Subject: [PATCH 09/15] wip Signed-off-by: alperozturk96 --- .../com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt | 8 ++++---- app/src/main/res/layout/file_info_fragment.xml | 6 +++--- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index a1cfad20f3ee..d951c0f55ab5 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -30,7 +30,7 @@ class GovernanceDetailInfo( fun init() { viewThemeUtils.material.themeCardView(binding.governanceLayout) initSensitivityLabel() - initFileDetentionLabel() + initFileRetentionLabel() } private fun initSensitivityLabel() { @@ -45,10 +45,10 @@ class GovernanceDetailInfo( ) } - private fun initFileDetentionLabel() { + private fun initFileRetentionLabel() { initDropdown( - textInputLayout = binding.fileDetentionLabel, - autoComplete = binding.fileDetentionAutoComplete, + textInputLayout = binding.fileRetentionLabel, + autoComplete = binding.fileRetentionAutoComplete, items = listOf( GovernanceLabel("Public", R.drawable.file_link), GovernanceLabel("Internal use only", R.drawable.ic_group), diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml index 8b746716a2db..3ae7570c8462 100644 --- a/app/src/main/res/layout/file_info_fragment.xml +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -61,14 +61,14 @@ + android:hint="@string/governance_file_retention_label"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a00de5822ff..0bb61387bec3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,7 +7,7 @@ --> Sensitivity label - File detention + File retention Governance Image details From 7d0fd5942274b4e6531c81e96aedfffaa5b39f5b Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Tue, 5 May 2026 11:03:09 +0200 Subject: [PATCH 10/15] wip Signed-off-by: alperozturk96 --- .../main/res/drawable/ic_dashboard_filled.xml | 17 ++++------------- .../main/res/drawable/ic_dashboard_outlined.xml | 17 ++++------------- .../main/res/layout/item_dropdown_with_icon.xml | 6 ++++++ 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/app/src/main/res/drawable/ic_dashboard_filled.xml b/app/src/main/res/drawable/ic_dashboard_filled.xml index 2d5b26893695..111b1e97f44a 100644 --- a/app/src/main/res/drawable/ic_dashboard_filled.xml +++ b/app/src/main/res/drawable/ic_dashboard_filled.xml @@ -1,18 +1,9 @@ + ~ SPDX-FileCopyrightText: 2018-2025 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + ~ SPDX-FileCopyrightText: 2018-2025 Google LLC + ~ SPDX-License-Identifier: Apache-2.0 +--> + Date: Tue, 5 May 2026 11:09:18 +0200 Subject: [PATCH 11/15] wip Signed-off-by: alperozturk96 --- app/src/main/res/layout/item_dropdown_with_icon.xml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/layout/item_dropdown_with_icon.xml b/app/src/main/res/layout/item_dropdown_with_icon.xml index 4df877f93896..ac31e3f898fd 100644 --- a/app/src/main/res/layout/item_dropdown_with_icon.xml +++ b/app/src/main/res/layout/item_dropdown_with_icon.xml @@ -6,10 +6,9 @@ ~ SPDX-License-Identifier: AGPL-3.0-or-later --> From 78cfa2ae87c0a621adf7fee50d8cce7f12f60b39 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 16 Jun 2026 09:41:15 +0200 Subject: [PATCH 12/15] show details depending on governance app Signed-off-by: tobiasKaminsky --- .../101.json | 1313 +++++++++++++++++ .../client/database/NextcloudDatabase.kt | 3 + .../database/entity/CapabilityEntity.kt | 3 + .../database/migrations/Migration100to101.kt | 93 ++ .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 9 +- .../datamodel/FileDataStorageManager.java | 2 + .../com/owncloud/android/db/ProviderMeta.java | 3 +- .../ui/adapter/FileDetailTabAdapter.kt | 20 +- .../ui/fragment/FileDetailFragment.java | 14 +- settings.gradle.kts | 7 +- 10 files changed, 1454 insertions(+), 13 deletions(-) create mode 100644 app/schemas/com.nextcloud.client.database.NextcloudDatabase/101.json create mode 100644 app/src/main/java/com/nextcloud/client/database/migrations/Migration100to101.kt diff --git a/app/schemas/com.nextcloud.client.database.NextcloudDatabase/101.json b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/101.json new file mode 100644 index 000000000000..b98265e2ee44 --- /dev/null +++ b/app/schemas/com.nextcloud.client.database.NextcloudDatabase/101.json @@ -0,0 +1,1313 @@ +{ + "formatVersion": 1, + "database": { + "version": 101, + "identityHash": "142ec7d5c0ae4ed96ff46d3e681144d2", + "entities": [ + { + "tableName": "arbitrary_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `cloud_id` TEXT, `key` TEXT, `value` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "cloudId", + "columnName": "cloud_id", + "affinity": "TEXT" + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT" + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `assistant` INTEGER, `account` TEXT, `version_mayor` INTEGER, `version_minor` INTEGER, `version_micro` INTEGER, `version_string` TEXT, `version_edition` TEXT, `extended_support` INTEGER, `core_pollinterval` INTEGER, `sharing_api_enabled` INTEGER, `sharing_public_enabled` INTEGER, `sharing_public_password_enforced` INTEGER, `sharing_public_expire_date_enabled` INTEGER, `sharing_public_expire_date_days` INTEGER, `sharing_public_expire_date_enforced` INTEGER, `sharing_public_send_mail` INTEGER, `sharing_public_upload` INTEGER, `sharing_user_send_mail` INTEGER, `sharing_resharing` INTEGER, `sharing_federation_outgoing` INTEGER, `sharing_federation_incoming` INTEGER, `files_bigfilechunking` INTEGER, `files_undelete` INTEGER, `files_versioning` INTEGER, `external_links` INTEGER, `server_name` TEXT, `server_color` TEXT, `server_text_color` TEXT, `server_element_color` TEXT, `server_slogan` TEXT, `server_logo` TEXT, `background_url` TEXT, `end_to_end_encryption` INTEGER, `end_to_end_encryption_keys_exist` INTEGER, `end_to_end_encryption_api_version` TEXT, `activity` INTEGER, `background_default` INTEGER, `background_plain` INTEGER, `richdocument` INTEGER, `richdocument_mimetype_list` TEXT, `richdocument_direct_editing` INTEGER, `richdocument_direct_templates` INTEGER, `richdocument_optional_mimetype_list` TEXT, `sharing_public_ask_for_optional_password` INTEGER, `richdocument_product_name` TEXT, `direct_editing_etag` TEXT, `user_status` INTEGER, `user_status_supports_emoji` INTEGER, `etag` TEXT, `files_locking_version` TEXT, `groupfolders` INTEGER, `drop_account` INTEGER, `security_guard` INTEGER, `governance` INTEGER, `forbidden_filename_characters` TEXT, `forbidden_filenames` TEXT, `forbidden_filename_extensions` TEXT, `forbidden_filename_basenames` TEXT, `files_download_limit` INTEGER, `files_download_limit_default` INTEGER, `recommendation` INTEGER, `notes_folder_path` TEXT, `default_permissions` INTEGER, `user_status_supports_busy` INTEGER, `windows_compatible_filenames` INTEGER, `has_valid_subscription` INTEGER, `client_integration_json` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "assistant", + "columnName": "assistant", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "versionMajor", + "columnName": "version_mayor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER" + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT" + }, + { + "fieldPath": "versionEditor", + "columnName": "version_edition", + "affinity": "TEXT" + }, + { + "fieldPath": "extendedSupport", + "columnName": "extended_support", + "affinity": "INTEGER" + }, + { + "fieldPath": "corePollinterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicSendMail", + "columnName": "sharing_public_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingUserSendMail", + "columnName": "sharing_user_send_mail", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesBigfilechunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER" + }, + { + "fieldPath": "externalLinks", + "columnName": "external_links", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverName", + "columnName": "server_name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverColor", + "columnName": "server_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverTextColor", + "columnName": "server_text_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverElementColor", + "columnName": "server_element_color", + "affinity": "TEXT" + }, + { + "fieldPath": "serverSlogan", + "columnName": "server_slogan", + "affinity": "TEXT" + }, + { + "fieldPath": "serverLogo", + "columnName": "server_logo", + "affinity": "TEXT" + }, + { + "fieldPath": "serverBackgroundUrl", + "columnName": "background_url", + "affinity": "TEXT" + }, + { + "fieldPath": "endToEndEncryption", + "columnName": "end_to_end_encryption", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionKeysExist", + "columnName": "end_to_end_encryption_keys_exist", + "affinity": "INTEGER" + }, + { + "fieldPath": "endToEndEncryptionApiVersion", + "columnName": "end_to_end_encryption_api_version", + "affinity": "TEXT" + }, + { + "fieldPath": "activity", + "columnName": "activity", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundDefault", + "columnName": "background_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "serverBackgroundPlain", + "columnName": "background_plain", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocument", + "columnName": "richdocument", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentMimetypeList", + "columnName": "richdocument_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "richdocumentDirectEditing", + "columnName": "richdocument_direct_editing", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentTemplates", + "columnName": "richdocument_direct_templates", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentOptionalMimetypeList", + "columnName": "richdocument_optional_mimetype_list", + "affinity": "TEXT" + }, + { + "fieldPath": "sharingPublicAskForOptionalPassword", + "columnName": "sharing_public_ask_for_optional_password", + "affinity": "INTEGER" + }, + { + "fieldPath": "richdocumentProductName", + "columnName": "richdocument_product_name", + "affinity": "TEXT" + }, + { + "fieldPath": "directEditingEtag", + "columnName": "direct_editing_etag", + "affinity": "TEXT" + }, + { + "fieldPath": "userStatus", + "columnName": "user_status", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsEmoji", + "columnName": "user_status_supports_emoji", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "filesLockingVersion", + "columnName": "files_locking_version", + "affinity": "TEXT" + }, + { + "fieldPath": "groupfolders", + "columnName": "groupfolders", + "affinity": "INTEGER" + }, + { + "fieldPath": "dropAccount", + "columnName": "drop_account", + "affinity": "INTEGER" + }, + { + "fieldPath": "securityGuard", + "columnName": "security_guard", + "affinity": "INTEGER" + }, + { + "fieldPath": "governance", + "columnName": "governance", + "affinity": "INTEGER" + }, + { + "fieldPath": "forbiddenFileNameCharacters", + "columnName": "forbidden_filename_characters", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFileNames", + "columnName": "forbidden_filenames", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFileNameExtensions", + "columnName": "forbidden_filename_extensions", + "affinity": "TEXT" + }, + { + "fieldPath": "forbiddenFilenameBaseNames", + "columnName": "forbidden_filename_basenames", + "affinity": "TEXT" + }, + { + "fieldPath": "filesDownloadLimit", + "columnName": "files_download_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "filesDownloadLimitDefault", + "columnName": "files_download_limit_default", + "affinity": "INTEGER" + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "INTEGER" + }, + { + "fieldPath": "notesFolderPath", + "columnName": "notes_folder_path", + "affinity": "TEXT" + }, + { + "fieldPath": "defaultPermissions", + "columnName": "default_permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "userStatusSupportsBusy", + "columnName": "user_status_supports_busy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWCFEnabled", + "columnName": "windows_compatible_filenames", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasValidSubscription", + "columnName": "has_valid_subscription", + "affinity": "INTEGER" + }, + { + "fieldPath": "clientIntegrationJson", + "columnName": "client_integration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "external_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `icon_url` TEXT, `language` TEXT, `type` INTEGER, `name` TEXT, `url` TEXT, `redirect` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT" + }, + { + "fieldPath": "redirect", + "columnName": "redirect", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filelist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `filename` TEXT, `encrypted_filename` TEXT, `path` TEXT, `path_decrypted` TEXT, `parent` INTEGER, `created` INTEGER, `modified` INTEGER, `content_type` TEXT, `content_length` INTEGER, `media_path` TEXT, `file_owner` TEXT, `last_sync_date` INTEGER, `last_sync_date_for_data` INTEGER, `modified_at_last_sync_for_data` INTEGER, `etag` TEXT, `etag_on_server` TEXT, `share_by_link` INTEGER, `permissions` TEXT, `remote_id` TEXT, `local_id` INTEGER NOT NULL DEFAULT -1, `update_thumbnail` INTEGER, `is_downloading` INTEGER, `favorite` INTEGER, `hidden` INTEGER, `is_encrypted` INTEGER, `etag_in_conflict` TEXT, `shared_via_users` INTEGER, `mount_type` INTEGER, `has_preview` INTEGER, `unread_comments_count` INTEGER, `owner_id` TEXT, `owner_display_name` TEXT, `note` TEXT, `sharees` TEXT, `rich_workspace` TEXT, `metadata_size` TEXT, `metadata_live_photo` TEXT, `locked` INTEGER, `lock_type` INTEGER, `lock_owner` TEXT, `lock_owner_display_name` TEXT, `lock_owner_editor` TEXT, `lock_timestamp` INTEGER, `lock_timeout` INTEGER, `lock_token` TEXT, `tags` TEXT, `metadata_gps` TEXT, `e2e_counter` INTEGER, `internal_two_way_sync_timestamp` INTEGER, `internal_two_way_sync_result` TEXT, `uploaded` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "filename", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptedName", + "columnName": "encrypted_filename", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "pathDecrypted", + "columnName": "path_decrypted", + "affinity": "TEXT" + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "creation", + "columnName": "created", + "affinity": "INTEGER" + }, + { + "fieldPath": "modified", + "columnName": "modified", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT" + }, + { + "fieldPath": "contentLength", + "columnName": "content_length", + "affinity": "INTEGER" + }, + { + "fieldPath": "storagePath", + "columnName": "media_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountOwner", + "columnName": "file_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lastSyncDate", + "columnName": "last_sync_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "last_sync_date_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modified_at_last_sync_for_data", + "affinity": "INTEGER" + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT" + }, + { + "fieldPath": "etagOnServer", + "columnName": "etag_on_server", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedViaLink", + "columnName": "share_by_link", + "affinity": "INTEGER" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteId", + "columnName": "remote_id", + "affinity": "TEXT" + }, + { + "fieldPath": "localId", + "columnName": "local_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "updateThumbnail", + "columnName": "update_thumbnail", + "affinity": "INTEGER" + }, + { + "fieldPath": "isDownloading", + "columnName": "is_downloading", + "affinity": "INTEGER" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "isEncrypted", + "columnName": "is_encrypted", + "affinity": "INTEGER" + }, + { + "fieldPath": "etagInConflict", + "columnName": "etag_in_conflict", + "affinity": "TEXT" + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "shared_via_users", + "affinity": "INTEGER" + }, + { + "fieldPath": "mountType", + "columnName": "mount_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "unreadCommentsCount", + "columnName": "unread_comments_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerDisplayName", + "columnName": "owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "sharees", + "columnName": "sharees", + "affinity": "TEXT" + }, + { + "fieldPath": "richWorkspace", + "columnName": "rich_workspace", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataSize", + "columnName": "metadata_size", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataLivePhoto", + "columnName": "metadata_live_photo", + "affinity": "TEXT" + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockType", + "columnName": "lock_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockOwner", + "columnName": "lock_owner", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerDisplayName", + "columnName": "lock_owner_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "lockOwnerEditor", + "columnName": "lock_owner_editor", + "affinity": "TEXT" + }, + { + "fieldPath": "lockTimestamp", + "columnName": "lock_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockTimeout", + "columnName": "lock_timeout", + "affinity": "INTEGER" + }, + { + "fieldPath": "lockToken", + "columnName": "lock_token", + "affinity": "TEXT" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataGPS", + "columnName": "metadata_gps", + "affinity": "TEXT" + }, + { + "fieldPath": "e2eCounter", + "columnName": "e2e_counter", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySync", + "columnName": "internal_two_way_sync_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "internalTwoWaySyncResult", + "columnName": "internal_two_way_sync_result", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "filesystem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `is_folder` INTEGER, `found_at` INTEGER, `upload_triggered` INTEGER, `syncedfolder_id` TEXT, `crc32` TEXT, `modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "fileIsFolder", + "columnName": "is_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileFoundRecently", + "columnName": "found_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSentForUpload", + "columnName": "upload_triggered", + "affinity": "INTEGER" + }, + { + "fieldPath": "syncedFolderId", + "columnName": "syncedfolder_id", + "affinity": "TEXT" + }, + { + "fieldPath": "crc32", + "columnName": "crc32", + "affinity": "TEXT" + }, + { + "fieldPath": "fileModified", + "columnName": "modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `file_source` INTEGER, `item_source` INTEGER, `share_type` INTEGER, `shate_with` TEXT, `path` TEXT, `permissions` INTEGER, `shared_date` INTEGER, `expiration_date` INTEGER, `token` TEXT, `shared_with_display_name` TEXT, `is_directory` INTEGER, `user_id` TEXT, `id_remote_shared` INTEGER, `owner_share` TEXT, `is_password_protected` INTEGER, `note` TEXT, `hide_download` INTEGER, `share_link` TEXT, `share_label` TEXT, `download_limit_limit` INTEGER, `download_limit_count` INTEGER, `attributes` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "fileSource", + "columnName": "file_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemSource", + "columnName": "item_source", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareWith", + "columnName": "shate_with", + "affinity": "TEXT" + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT" + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER" + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT" + }, + { + "fieldPath": "shareWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT" + }, + { + "fieldPath": "isDirectory", + "columnName": "is_directory", + "affinity": "INTEGER" + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT" + }, + { + "fieldPath": "idRemoteShared", + "columnName": "id_remote_shared", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT" + }, + { + "fieldPath": "isPasswordProtected", + "columnName": "is_password_protected", + "affinity": "INTEGER" + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, + { + "fieldPath": "hideDownload", + "columnName": "hide_download", + "affinity": "INTEGER" + }, + { + "fieldPath": "shareLink", + "columnName": "share_link", + "affinity": "TEXT" + }, + { + "fieldPath": "shareLabel", + "columnName": "share_label", + "affinity": "TEXT" + }, + { + "fieldPath": "downloadLimitLimit", + "columnName": "download_limit_limit", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadLimitCount", + "columnName": "download_limit_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "attributes", + "columnName": "attributes", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "synced_folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `wifi_only` INTEGER, `charging_only` INTEGER, `existing` INTEGER, `enabled` INTEGER, `enabled_timestamp_ms` INTEGER, `subfolder_by_date` INTEGER, `account` TEXT, `upload_option` INTEGER, `name_collision_policy` INTEGER, `type` INTEGER, `hidden` INTEGER, `sub_folder_rule` INTEGER, `exclude_hidden` INTEGER, `last_scan_timestamp_ms` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "chargingOnly", + "columnName": "charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "existing", + "columnName": "existing", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "enabledTimestampMs", + "columnName": "enabled_timestamp_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "subfolderByDate", + "columnName": "subfolder_by_date", + "affinity": "INTEGER" + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "TEXT" + }, + { + "fieldPath": "uploadAction", + "columnName": "upload_option", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER" + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "subFolderRule", + "columnName": "sub_folder_rule", + "affinity": "INTEGER" + }, + { + "fieldPath": "excludeHidden", + "columnName": "exclude_hidden", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastScanTimestampMs", + "columnName": "last_scan_timestamp_ms", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "list_of_uploads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `local_path` TEXT, `remote_path` TEXT, `account_name` TEXT, `file_size` INTEGER, `status` INTEGER, `local_behaviour` INTEGER, `upload_time` INTEGER, `name_collision_policy` INTEGER, `is_create_remote_folder` INTEGER, `upload_end_timestamp` INTEGER, `upload_end_timestamp_long` INTEGER, `last_result` INTEGER, `is_while_charging_only` INTEGER, `is_wifi_only` INTEGER, `created_by` INTEGER, `folder_unlock_token` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "localPath", + "columnName": "local_path", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePath", + "columnName": "remote_path", + "affinity": "TEXT" + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER" + }, + { + "fieldPath": "localBehaviour", + "columnName": "local_behaviour", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadTime", + "columnName": "upload_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "nameCollisionPolicy", + "columnName": "name_collision_policy", + "affinity": "INTEGER" + }, + { + "fieldPath": "isCreateRemoteFolder", + "columnName": "is_create_remote_folder", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestamp", + "columnName": "upload_end_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "uploadEndTimestampLong", + "columnName": "upload_end_timestamp_long", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastResult", + "columnName": "last_result", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWhileChargingOnly", + "columnName": "is_while_charging_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "isWifiOnly", + "columnName": "is_wifi_only", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "folderUnlockToken", + "columnName": "folder_unlock_token", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "virtual", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `type` TEXT, `ocfile_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "ocFileId", + "columnName": "ocfile_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "offline_operations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `offline_operations_parent_oc_file_id` INTEGER, `offline_operations_path` TEXT, `offline_operations_type` TEXT, `offline_operations_file_name` TEXT, `offline_operations_created_at` INTEGER, `offline_operations_modified_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "parentOCFileId", + "columnName": "offline_operations_parent_oc_file_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "path", + "columnName": "offline_operations_path", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "offline_operations_type", + "affinity": "TEXT" + }, + { + "fieldPath": "filename", + "columnName": "offline_operations_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "offline_operations_created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifiedAt", + "columnName": "offline_operations_modified_at", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "recommended_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `directory` TEXT NOT NULL, `extension` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `has_preview` INTEGER NOT NULL, `reason` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `account_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directory", + "columnName": "directory", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extension", + "columnName": "extension", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "_id" + ] + } + }, + { + "tableName": "assistant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountName` TEXT, `type` TEXT, `status` TEXT, `userId` TEXT, `appId` TEXT, `input` TEXT, `output` TEXT, `completionExpectedAt` INTEGER, `progress` INTEGER, `lastUpdated` INTEGER, `scheduledAt` INTEGER, `endedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT" + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "TEXT" + }, + { + "fieldPath": "appId", + "columnName": "appId", + "affinity": "TEXT" + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "TEXT" + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "TEXT" + }, + { + "fieldPath": "completionExpectedAt", + "columnName": "completionExpectedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER" + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '142ec7d5c0ae4ed96ff46d3e681144d2')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt index 39fa5c7b33ae..0cc09011b882 100644 --- a/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt +++ b/app/src/main/java/com/nextcloud/client/database/NextcloudDatabase.kt @@ -40,6 +40,7 @@ import com.nextcloud.client.database.entity.VirtualEntity import com.nextcloud.client.database.migrations.DatabaseMigrationUtil import com.nextcloud.client.database.migrations.MIGRATION_88_89 import com.nextcloud.client.database.migrations.MIGRATION_97_98 +import com.nextcloud.client.database.migrations.MIGRATION_100_101 import com.nextcloud.client.database.migrations.MIGRATION_99_100 import com.nextcloud.client.database.migrations.Migration67to68 import com.nextcloud.client.database.migrations.RoomMigration @@ -99,6 +100,7 @@ import com.owncloud.android.db.ProviderMeta // manual migration used for 97 to 98 AutoMigration(from = 98, to = 99) // manual migration used for 99 to 100 + // manual migration used for 100 to 101 ], exportSchema = true ) @@ -139,6 +141,7 @@ abstract class NextcloudDatabase : RoomDatabase() { .addMigrations(MIGRATION_88_89) .addMigrations(MIGRATION_97_98) .addMigrations(MIGRATION_99_100) + .addMigrations(MIGRATION_100_101) .build() } return instance!! diff --git a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt index eb225bab7972..feb27a658a02 100644 --- a/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt +++ b/app/src/main/java/com/nextcloud/client/database/entity/CapabilityEntity.kt @@ -126,6 +126,8 @@ data class CapabilityEntity( val dropAccount: Int?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_SECURITY_GUARD) val securityGuard: Int?, + @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_GOVERNANCE) + val governance: Int?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS) val forbiddenFileNameCharacters: String?, @ColumnInfo(name = ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES) @@ -220,6 +222,7 @@ fun CapabilityEntity?.toOCCapability(): OCCapability { capability.groupfolders = intToBoolean(this.groupfolders) capability.dropAccount = intToBoolean(this.dropAccount) capability.securityGuard = intToBoolean(this.securityGuard) + capability.governance = intToBoolean(this.governance) capability.forbiddenFilenameCharactersJson = this.forbiddenFileNameCharacters capability.forbiddenFilenamesJson = this.forbiddenFileNames capability.forbiddenFilenameExtensionJson = this.forbiddenFileNameExtensions diff --git a/app/src/main/java/com/nextcloud/client/database/migrations/Migration100to101.kt b/app/src/main/java/com/nextcloud/client/database/migrations/Migration100to101.kt new file mode 100644 index 000000000000..e4b5232859d1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/database/migrations/Migration100to101.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.client.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +@Suppress("MagicNumber", "LongMethod") +val MIGRATION_100_101 = object : Migration(100, 101) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("DROP TABLE IF EXISTS capabilities") + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS capabilities ( + _id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT, + version_mayor INTEGER, + version_minor INTEGER, + version_micro INTEGER, + version_string TEXT, + version_edition TEXT, + extended_support INTEGER, + core_pollinterval INTEGER, + sharing_api_enabled INTEGER, + sharing_public_enabled INTEGER, + sharing_public_password_enforced INTEGER, + sharing_public_ask_for_optional_password INTEGER, + sharing_public_expire_date_enabled INTEGER, + sharing_public_expire_date_days INTEGER, + sharing_public_expire_date_enforced INTEGER, + sharing_public_send_mail INTEGER, + sharing_public_upload INTEGER, + sharing_user_send_mail INTEGER, + sharing_resharing INTEGER, + sharing_federation_outgoing INTEGER, + sharing_federation_incoming INTEGER, + files_bigfilechunking INTEGER, + files_undelete INTEGER, + files_versioning INTEGER, + files_locking_version TEXT, + external_links INTEGER, + server_name TEXT, + server_color TEXT, + server_text_color TEXT, + server_element_color TEXT, + background_url TEXT, + server_slogan TEXT, + server_logo TEXT, + background_default INTEGER, + background_plain INTEGER, + end_to_end_encryption INTEGER, + end_to_end_encryption_keys_exist INTEGER, + end_to_end_encryption_api_version TEXT, + activity INTEGER, + richdocument INTEGER, + recommendation INTEGER, + richdocument_mimetype_list TEXT, + richdocument_optional_mimetype_list TEXT, + richdocument_direct_editing INTEGER, + richdocument_direct_templates INTEGER, + richdocument_product_name TEXT, + direct_editing_etag TEXT, + etag TEXT, + user_status INTEGER, + user_status_supports_emoji INTEGER, + user_status_supports_busy INTEGER, + assistant INTEGER, + groupfolders INTEGER, + drop_account INTEGER, + security_guard INTEGER, + governance INTEGER, + forbidden_filename_characters TEXT, + forbidden_filenames TEXT, + forbidden_filename_extensions TEXT, + forbidden_filename_basenames TEXT, + windows_compatible_filenames INTEGER, + files_download_limit INTEGER, + files_download_limit_default INTEGER, + notes_folder_path TEXT, + default_permissions INTEGER, + has_valid_subscription INTEGER, + client_integration_json TEXT + ) + """.trimIndent() + ) + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index 3eddf0c3e0ac..ce2c2f6b51ee 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -19,6 +19,7 @@ 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.CapabilityUtils import com.owncloud.android.utils.theme.ViewThemeUtils import javax.inject.Inject @@ -46,8 +47,12 @@ class FileInfoFragment : file?.let { imageDetailInfo.init(it, binding) } } - val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) - governanceDetailInfo.init() + if (CapabilityUtils.getCapability(context).governance.isTrue) { + val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) + governanceDetailInfo.init() + } else { + binding.governanceLayout.visibility = View.GONE + } return binding.root } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index a82ad391bb5d..1e2cfd25c8ed 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2395,6 +2395,7 @@ private ContentValues createContentValues(String accountName, OCCapability capab contentValues.put(ProviderTableMeta.CAPABILITIES_GROUPFOLDERS, capability.getGroupfolders().getValue()); contentValues.put(ProviderTableMeta.CAPABILITIES_DROP_ACCOUNT, capability.getDropAccount().getValue()); contentValues.put(ProviderTableMeta.CAPABILITIES_SECURITY_GUARD, capability.getSecurityGuard().getValue()); + contentValues.put(ProviderTableMeta.CAPABILITIES_GOVERNANCE, capability.getGovernance().getValue()); contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS, capability.getForbiddenFilenameCharactersJson()); contentValues.put(ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES, capability.getForbiddenFilenamesJson()); @@ -2584,6 +2585,7 @@ private OCCapability createCapabilityInstance(Cursor cursor) { capability.setGroupfolders(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_GROUPFOLDERS)); capability.setDropAccount(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_DROP_ACCOUNT)); capability.setSecurityGuard(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_SECURITY_GUARD)); + capability.setGovernance(getBoolean(cursor, ProviderTableMeta.CAPABILITIES_GOVERNANCE)); capability.setForbiddenFilenameCharactersJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS)); capability.setForbiddenFilenamesJson(getString(cursor, ProviderTableMeta.CAPABILITIES_FORBIDDEN_FILENAMES)); diff --git a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java index e12b0b44ec54..4e497e32eafa 100644 --- a/app/src/main/java/com/owncloud/android/db/ProviderMeta.java +++ b/app/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -23,7 +23,7 @@ */ public class ProviderMeta { public static final String DB_NAME = "filelist"; - public static final int DB_VERSION = 100; + public static final int DB_VERSION = 101; private ProviderMeta() { // No instance @@ -282,6 +282,7 @@ static public class ProviderTableMeta implements BaseColumns { public static final String CAPABILITIES_GROUPFOLDERS = "groupfolders"; public static final String CAPABILITIES_DROP_ACCOUNT = "drop_account"; public static final String CAPABILITIES_SECURITY_GUARD = "security_guard"; + public static final String CAPABILITIES_GOVERNANCE = "governance"; public static final String CAPABILITIES_FORBIDDEN_FILENAME_CHARACTERS = "forbidden_filename_characters"; public static final String CAPABILITIES_FORBIDDEN_FILENAMES = "forbidden_filenames"; public static final String CAPABILITIES_FORBIDDEN_FORBIDDEN_FILENAME_EXTENSIONS = "forbidden_filename_extensions"; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt index 9fbcba02eb8f..7633297ce066 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/FileDetailTabAdapter.kt @@ -17,10 +17,11 @@ import com.owncloud.android.ui.fragment.FileDetailActivitiesFragment import com.owncloud.android.ui.fragment.FileDetailSharingFragment class FileDetailTabAdapter( - fragmentActivity: FragmentActivity, + private val fragmentActivity: FragmentActivity, private val file: OCFile, private val user: User, - private val showSharingTab: Boolean + private val showSharingTab: Boolean, + private val showDetailsTab: Boolean ) : FragmentStateAdapter(fragmentActivity) { private enum class Tab(val position: Int) { @@ -44,5 +45,18 @@ class FileDetailTabAdapter( .also { fileDetailActivitiesFragment = it } } - override fun getItemCount(): Int = if (showSharingTab) Tab.entries.size else Tab.entries.size - 1 + override fun getItemCount(): Int { + // always show Activities + var count = 1 + + if (showSharingTab) { + count++ + } + + if (showDetailsTab) { + count++ + } + + return count + } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java index 2f5a717b3d2f..3a7e4dd66566 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/FileDetailFragment.java @@ -36,7 +36,6 @@ import com.nextcloud.ui.fileactions.FileActionsBottomSheet; import com.nextcloud.utils.MenuUtils; import com.nextcloud.utils.extensions.BundleExtensionsKt; -import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.mdm.MDMConfig; import com.owncloud.android.MainApp; import com.owncloud.android.R; @@ -65,6 +64,7 @@ import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.MimeTypeUtil; +import com.owncloud.android.utils.theme.CapabilityUtils; import com.owncloud.android.utils.theme.ViewThemeUtils; import org.greenrobot.eventbus.EventBus; @@ -316,14 +316,17 @@ private void setupViewPager() { binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.share_dialog_title).setIcon(R.drawable.selector_tab_share)); } - binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_file_info)); + if (showDetailsTab()) { + binding.tabLayout.addTab(binding.tabLayout.newTab().setText(R.string.filedetails_details).setIcon(R.drawable.selector_file_info)); + } viewThemeUtils.material.themeTabLayout(binding.tabLayout); final FileDetailTabAdapter adapter = new FileDetailTabAdapter(requireActivity(), getFile(), user, - showSharingTab()); + showSharingTab(), + showDetailsTab()); binding.pager.setAdapter(adapter); binding.pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @@ -898,4 +901,9 @@ private boolean showSharingTab() { return true; } } + + private boolean showDetailsTab() { + return CapabilityUtils.getCapability(getContext()).getGovernance().isTrue() || + MimeTypeUtil.isMedia(getFile().getMimeType()); + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 651d7a880838..7a4fca194396 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,20 +43,19 @@ dependencyResolutionManagement { /* Needed for local android library - -includeBuild("../android_library") { +*/ +includeBuild("../android-library") { dependencySubstitution { substitute(module("com.github.nextcloud:android-library")) .using(project(":library")) } } -*/ /* Needed for local android common library -includeBuild("../android_common") { +includeBuild("../android-common") { dependencySubstitution { substitute(module("com.github.nextcloud.android-common:core")) .using(project(":core")) From 4a94596a2b7924da736704d346c9dfc4619bd068 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 23 Jun 2026 08:16:08 +0200 Subject: [PATCH 13/15] wip Signed-off-by: tobiasKaminsky --- .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 14 ++- .../ui/fileInfo/GovernanceDetailInfo.kt | 119 ++++++++++++++---- .../ui/fileInfo/model/GovernanceLabel.kt | 2 +- .../extensions/OwnCloudClientExtensions.kt | 4 +- .../datamodel/ThumbnailsCacheManager.java | 2 +- app/src/main/res/values/dims.xml | 1 + gradle/verification-metadata.xml | 17 ++- 7 files changed, 128 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index ce2c2f6b51ee..e4a9ff7bd887 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -16,6 +16,7 @@ import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment import com.nextcloud.client.account.User import com.nextcloud.client.di.Injectable +import com.nextcloud.client.network.ClientFactory import com.owncloud.android.databinding.FileInfoFragmentBinding import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.MimeTypeUtil @@ -28,17 +29,20 @@ class FileInfoFragment : Injectable { private lateinit var binding: FileInfoFragmentBinding - private val file: OCFile? by lazy { + val file: OCFile? by lazy { arguments?.let { BundleCompat.getParcelable(it, ARG_FILE, OCFile::class.java) } } - private val user: User? by lazy { + val user: User? by lazy { arguments?.let { BundleCompat.getParcelable(it, ARG_USER, User::class.java) } } @Inject lateinit var viewThemeUtils: ViewThemeUtils + @Inject + lateinit var clientFactory: ClientFactory + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) @@ -47,14 +51,16 @@ class FileInfoFragment : file?.let { imageDetailInfo.init(it, binding) } } + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (CapabilityUtils.getCapability(context).governance.isTrue) { val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) governanceDetailInfo.init() } else { binding.governanceLayout.visibility = View.GONE } - - return binding.root } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index d951c0f55ab5..2debb8b84f62 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -7,18 +7,30 @@ package com.nextcloud.ui.fileInfo +import android.graphics.drawable.Drawable +import android.graphics.drawable.GradientDrawable import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.TextView import androidx.core.content.ContextCompat +import androidx.core.graphics.toColorInt +import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.textfield.TextInputLayout -import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.android.lib.resources.governance.GetAvailableRetentionLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.GetAvailableSensitivityLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.RetentionLabelInfo +import com.nextcloud.android.lib.resources.governance.SensitivityLabelInfo +import com.nextcloud.client.network.ClientFactoryImpl import com.nextcloud.ui.fileInfo.model.GovernanceLabel import com.owncloud.android.R import com.owncloud.android.databinding.FileInfoFragmentBinding +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.utils.theme.ViewThemeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class GovernanceDetailInfo( private val binding: FileInfoFragmentBinding, @@ -34,29 +46,77 @@ class GovernanceDetailInfo( } 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) + fragment.lifecycleScope.launch { + val labels = withContext(Dispatchers.IO) { fetchAvailableSensitivityLabels() } + + if (labels.isEmpty()) { + binding.sensitivityLabel.visibility = View.GONE + return@launch + } + + initDropdown( + textInputLayout = binding.sensitivityLabel, + autoComplete = binding.sensitivityLabelAutoComplete, + items = labels ) - ) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun fetchAvailableSensitivityLabels(): List = try { + val user = fragment.user ?: return emptyList() + val file = fragment.file ?: return emptyList() + val client = ClientFactoryImpl(context).createNextcloudClient(user) + val result = GetAvailableSensitivityLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) + + if (result.isSuccess) { + result.resultData.orEmpty().map { it.toGovernanceLabel() } + } else { + emptyList() + } + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch available sensitivity labels", e) + emptyList() } + private fun SensitivityLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) + 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) + fragment.lifecycleScope.launch { + val labels = withContext(Dispatchers.IO) { fetchAvailableRetentionLabels() } + + if (labels.isEmpty()) { + binding.fileRetentionLabel.visibility = View.GONE + return@launch + } + + initDropdown( + textInputLayout = binding.fileRetentionLabel, + autoComplete = binding.fileRetentionAutoComplete, + items = labels ) - ) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun fetchAvailableRetentionLabels(): List = try { + val user = fragment.user ?: return emptyList() + val file = fragment.file ?: return emptyList() + val client = ClientFactoryImpl(context).createNextcloudClient(user) + val result = GetAvailableRetentionLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) + + if (result.isSuccess) { + result.resultData.orEmpty().map { it.toGovernanceLabel() } + } else { + emptyList() + } + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch available retention labels", e) + emptyList() } + private fun RetentionLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) + private fun initDropdown( textInputLayout: TextInputLayout, autoComplete: MaterialAutoCompleteTextView, @@ -80,7 +140,7 @@ class GovernanceDetailInfo( val view = super.getView(position, convertView, parent) as TextView getItem(position)?.let { item -> view.text = item.text - view.setCompoundDrawablesWithIntrinsicBounds(tintedDrawable(item), null, null, null) + view.setCompoundDrawablesWithIntrinsicBounds(colorDot(item.color), null, null, null) } return view } @@ -88,12 +148,27 @@ class GovernanceDetailInfo( private fun applySelection(autoComplete: MaterialAutoCompleteTextView, item: GovernanceLabel) { autoComplete.setText(item.text, false) - autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(tintedDrawable(item), null, null, null) + autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(colorDot(item.color), 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) + private fun colorDot(color: String): Drawable { + val size = context.resources.getDimensionPixelSize(R.dimen.governance_color_dot_size) + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setSize(size, size) + setColor(parseColor(color)) } + } + + private fun parseColor(color: String): Int = runCatching { color.toColorInt() } + .getOrElse { + Log_OC.w(TAG, "Could not parse label color: $color") + ContextCompat.getColor(context, R.color.grey_600) + } + + companion object { + private val TAG = GovernanceDetailInfo::class.java.simpleName + private const val ENTITY_TYPE_FILES = "FILES" + } } 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 index 1904c790c40d..b8f1f4749cc5 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt @@ -7,4 +7,4 @@ package com.nextcloud.ui.fileInfo.model -data class GovernanceLabel(val text: String, val iconRes: Int) +data class GovernanceLabel(val text: String, val color: String) diff --git a/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt index 06e4ff1a9b11..6039821d2276 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/OwnCloudClientExtensions.kt @@ -21,9 +21,9 @@ fun OwnCloudClient.toNextcloudClient(context: Context): NextcloudClient = OwnClo isFollowRedirects ) -fun OwnCloudClient.getPreviewEndpoint(remoteId: String, x: Int, y: Int): String = baseUri +fun OwnCloudClient.getPreviewEndpoint(fileId: Long, x: Int, y: Int): String = baseUri .toString() + "/index.php/core/preview?fileId=" + - remoteId + + fileId + "&x=" + (x / 2) + "&y=" + (y / 2) + "&a=1&mode=cover&forceIcon=0" diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index 3586f6adeba5..a71a1d77b5f0 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -1286,7 +1286,7 @@ public static Bitmap doResizedImageInBackground(OCFile file, FileDataStorageMana GetMethod getMethod = null; try { - String uri = OwnCloudClientExtensionsKt.getPreviewEndpoint(mClient, file.getRemoteId(), pxW, pxH); + String uri = OwnCloudClientExtensionsKt.getPreviewEndpoint(mClient, file.getLocalId(), pxW, pxH); Log_OC.d(TAG, "generating resized image: " + file.getFileName() + " URI: " + uri); getMethod = new GetMethod(uri); diff --git a/app/src/main/res/values/dims.xml b/app/src/main/res/values/dims.xml index 0d1890a47000..caa661d4fda6 100644 --- a/app/src/main/res/values/dims.xml +++ b/app/src/main/res/values/dims.xml @@ -31,6 +31,7 @@ 8dp 4dp 32dp + 12dp 2dp 16dp 32dp diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 9b38429f9e72..3290234bce51 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -197,6 +197,7 @@ + @@ -257,6 +258,7 @@ + @@ -521,12 +523,17 @@ + - + + + + + @@ -20059,6 +20066,11 @@ + + + + + @@ -28005,6 +28017,9 @@ + + + From 851b64736a6eb4f132d768aeb382693d43090d02 Mon Sep 17 00:00:00 2001 From: tobiasKaminsky Date: Tue, 23 Jun 2026 09:06:39 +0200 Subject: [PATCH 14/15] wip Signed-off-by: tobiasKaminsky --- .../nextcloud/client/di/ViewModelModule.kt | 6 ++ .../nextcloud/ui/fileInfo/FileInfoFragment.kt | 17 +++- .../ui/fileInfo/FileInfoViewModel.kt | 74 ++++++++++++++++ .../ui/fileInfo/GovernanceDetailInfo.kt | 84 ++++--------------- .../main/res/layout/file_info_fragment.xml | 4 + 5 files changed, 113 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt diff --git a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt index eb3e98a6c570..c0a3c1a85927 100644 --- a/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/client/di/ViewModelModule.kt @@ -12,6 +12,7 @@ import androidx.lifecycle.ViewModelProvider import com.nextcloud.client.documentscan.DocumentScanViewModel import com.nextcloud.client.etm.EtmViewModel import com.nextcloud.client.logger.ui.LogsViewModel +import com.nextcloud.ui.fileInfo.FileInfoViewModel import com.nextcloud.ui.fileactions.FileActionsViewModel import com.owncloud.android.ui.preview.pdf.PreviewPdfViewModel import com.nextcloud.ui.trashbinFileActions.TrashbinFileActionsViewModel @@ -57,6 +58,11 @@ abstract class ViewModelModule { @ViewModelKey(TrashbinFileActionsViewModel::class) abstract fun trashbinFileActionsViewModel(vm: TrashbinFileActionsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(FileInfoViewModel::class) + abstract fun fileInfoViewModel(vm: FileInfoViewModel): ViewModel + @Binds abstract fun bindViewModelFactory(factory: ViewModelFactory): ViewModelProvider.Factory } diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt index e4a9ff7bd887..d3c792745275 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoFragment.kt @@ -14,9 +14,10 @@ import android.view.ViewGroup import androidx.annotation.VisibleForTesting import androidx.core.os.BundleCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider import com.nextcloud.client.account.User import com.nextcloud.client.di.Injectable -import com.nextcloud.client.network.ClientFactory +import com.nextcloud.client.di.ViewModelFactory import com.owncloud.android.databinding.FileInfoFragmentBinding import com.owncloud.android.datamodel.OCFile import com.owncloud.android.utils.MimeTypeUtil @@ -41,7 +42,17 @@ class FileInfoFragment : lateinit var viewThemeUtils: ViewThemeUtils @Inject - lateinit var clientFactory: ClientFactory + lateinit var vmFactory: ViewModelFactory + + private lateinit var viewModel: FileInfoViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel = ViewModelProvider(this, vmFactory)[FileInfoViewModel::class.java] + val file = file ?: return + val user = user ?: return + viewModel.load(file, user) + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FileInfoFragmentBinding.inflate(layoutInflater, container, false) @@ -56,7 +67,7 @@ class FileInfoFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (CapabilityUtils.getCapability(context).governance.isTrue) { - val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this) + val governanceDetailInfo = GovernanceDetailInfo(binding, viewThemeUtils, this, viewModel) governanceDetailInfo.init() } else { binding.governanceLayout.visibility = View.GONE diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt new file mode 100644 index 000000000000..80b300234bbb --- /dev/null +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt @@ -0,0 +1,74 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.ui.fileInfo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nextcloud.android.lib.resources.governance.GetAvailableRetentionLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.GetAvailableSensitivityLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.RetentionLabelInfo +import com.nextcloud.android.lib.resources.governance.SensitivityLabelInfo +import com.nextcloud.client.account.User +import com.nextcloud.client.network.ClientFactory +import com.nextcloud.ui.fileInfo.model.GovernanceLabel +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +class FileInfoViewModel @Inject constructor( + private val clientFactory: ClientFactory +) : ViewModel() { + + private val _sensitivityLabels = MutableStateFlow?>(null) + val sensitivityLabels: StateFlow?> = _sensitivityLabels + + private val _retentionLabels = MutableStateFlow?>(null) + val retentionLabels: StateFlow?> = _retentionLabels + + fun load(file: OCFile, user: User) { + viewModelScope.launch(Dispatchers.IO) { + _sensitivityLabels.value = fetchSensitivityLabels(file, user) + } + viewModelScope.launch(Dispatchers.IO) { + _retentionLabels.value = fetchRetentionLabels(file, user) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun fetchSensitivityLabels(file: OCFile, user: User): List = try { + val client = clientFactory.createNextcloudClient(user) + val result = GetAvailableSensitivityLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) + if (result.isSuccess) result.resultData.orEmpty().map { it.toGovernanceLabel() } else emptyList() + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch available sensitivity labels", e) + emptyList() + } + + @Suppress("TooGenericExceptionCaught") + private fun fetchRetentionLabels(file: OCFile, user: User): List = try { + val client = clientFactory.createNextcloudClient(user) + val result = GetAvailableRetentionLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) + if (result.isSuccess) result.resultData.orEmpty().map { it.toGovernanceLabel() } else emptyList() + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch available retention labels", e) + emptyList() + } + + private fun SensitivityLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) + + private fun RetentionLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) + + companion object { + private val TAG = FileInfoViewModel::class.java.simpleName + private const val ENTITY_TYPE_FILES = "FILES" + } +} diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index 2debb8b84f62..56d694a6da99 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -18,110 +18,57 @@ import androidx.core.graphics.toColorInt import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.MaterialAutoCompleteTextView import com.google.android.material.textfield.TextInputLayout -import com.nextcloud.android.lib.resources.governance.GetAvailableRetentionLabelsRemoteOperation -import com.nextcloud.android.lib.resources.governance.GetAvailableSensitivityLabelsRemoteOperation -import com.nextcloud.android.lib.resources.governance.RetentionLabelInfo -import com.nextcloud.android.lib.resources.governance.SensitivityLabelInfo -import com.nextcloud.client.network.ClientFactoryImpl import com.nextcloud.ui.fileInfo.model.GovernanceLabel import com.owncloud.android.R import com.owncloud.android.databinding.FileInfoFragmentBinding import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.utils.theme.ViewThemeUtils -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class GovernanceDetailInfo( private val binding: FileInfoFragmentBinding, private val viewThemeUtils: ViewThemeUtils, - private val fragment: FileInfoFragment + private val fragment: FileInfoFragment, + private val viewModel: FileInfoViewModel ) { private val context get() = fragment.requireContext() fun init() { viewThemeUtils.material.themeCardView(binding.governanceLayout) - initSensitivityLabel() - initFileRetentionLabel() + collectSensitivityLabels() + collectRetentionLabels() } - private fun initSensitivityLabel() { + private fun collectSensitivityLabels() { fragment.lifecycleScope.launch { - val labels = withContext(Dispatchers.IO) { fetchAvailableSensitivityLabels() } - + val labels = viewModel.sensitivityLabels.filterNotNull().first() if (labels.isEmpty()) { binding.sensitivityLabel.visibility = View.GONE - return@launch + } else { + initDropdown(binding.sensitivityLabel, binding.sensitivityLabelAutoComplete, labels) } - - initDropdown( - textInputLayout = binding.sensitivityLabel, - autoComplete = binding.sensitivityLabelAutoComplete, - items = labels - ) } } - @Suppress("TooGenericExceptionCaught") - private fun fetchAvailableSensitivityLabels(): List = try { - val user = fragment.user ?: return emptyList() - val file = fragment.file ?: return emptyList() - val client = ClientFactoryImpl(context).createNextcloudClient(user) - val result = GetAvailableSensitivityLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) - - if (result.isSuccess) { - result.resultData.orEmpty().map { it.toGovernanceLabel() } - } else { - emptyList() - } - } catch (e: Exception) { - Log_OC.e(TAG, "Could not fetch available sensitivity labels", e) - emptyList() - } - - private fun SensitivityLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) - - private fun initFileRetentionLabel() { + private fun collectRetentionLabels() { fragment.lifecycleScope.launch { - val labels = withContext(Dispatchers.IO) { fetchAvailableRetentionLabels() } - + val labels = viewModel.retentionLabels.filterNotNull().first() if (labels.isEmpty()) { binding.fileRetentionLabel.visibility = View.GONE - return@launch + } else { + initDropdown(binding.fileRetentionLabel, binding.fileRetentionAutoComplete, labels) } - - initDropdown( - textInputLayout = binding.fileRetentionLabel, - autoComplete = binding.fileRetentionAutoComplete, - items = labels - ) } } - @Suppress("TooGenericExceptionCaught") - private fun fetchAvailableRetentionLabels(): List = try { - val user = fragment.user ?: return emptyList() - val file = fragment.file ?: return emptyList() - val client = ClientFactoryImpl(context).createNextcloudClient(user) - val result = GetAvailableRetentionLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId).execute(client) - - if (result.isSuccess) { - result.resultData.orEmpty().map { it.toGovernanceLabel() } - } else { - emptyList() - } - } catch (e: Exception) { - Log_OC.e(TAG, "Could not fetch available retention labels", e) - emptyList() - } - - private fun RetentionLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) - private fun initDropdown( textInputLayout: TextInputLayout, autoComplete: MaterialAutoCompleteTextView, items: List ) { + textInputLayout.visibility = View.VISIBLE viewThemeUtils.material.colorTextInputLayout(textInputLayout) viewThemeUtils.files.themeAutoCompleteTextView(autoComplete) @@ -169,6 +116,5 @@ class GovernanceDetailInfo( companion object { private val TAG = GovernanceDetailInfo::class.java.simpleName - private const val ENTITY_TYPE_FILES = "FILES" } } diff --git a/app/src/main/res/layout/file_info_fragment.xml b/app/src/main/res/layout/file_info_fragment.xml index 3ae7570c8462..10603b333e73 100644 --- a/app/src/main/res/layout/file_info_fragment.xml +++ b/app/src/main/res/layout/file_info_fragment.xml @@ -49,6 +49,8 @@ style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu" android:layout_width="match_parent" android:layout_height="wrap_content" + android:visibility="gone" + tools:visibility="visible" android:layout_marginBottom="@dimen/standard_margin" android:hint="@string/governance_sensitivity_label"> @@ -65,6 +67,8 @@ style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu" android:layout_width="match_parent" android:layout_height="wrap_content" + android:visibility="gone" + tools:visibility="visible" android:hint="@string/governance_file_retention_label"> Date: Tue, 23 Jun 2026 10:47:15 +0200 Subject: [PATCH 15/15] wip Signed-off-by: tobiasKaminsky --- .../ui/fileInfo/FileInfoViewModel.kt | 71 ++++++++++++++++++- .../ui/fileInfo/GovernanceDetailInfo.kt | 48 ++++++++++--- .../ui/fileInfo/model/GovernanceLabel.kt | 4 +- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt index 80b300234bbb..267d76632e09 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/FileInfoViewModel.kt @@ -9,10 +9,15 @@ package com.nextcloud.ui.fileInfo import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nextcloud.android.lib.resources.governance.EntityLabels import com.nextcloud.android.lib.resources.governance.GetAvailableRetentionLabelsRemoteOperation import com.nextcloud.android.lib.resources.governance.GetAvailableSensitivityLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.GetEntityLabelsRemoteOperation +import com.nextcloud.android.lib.resources.governance.LabelType +import com.nextcloud.android.lib.resources.governance.RemoveLabelRemoteOperation import com.nextcloud.android.lib.resources.governance.RetentionLabelInfo import com.nextcloud.android.lib.resources.governance.SensitivityLabelInfo +import com.nextcloud.android.lib.resources.governance.SetLabelRemoteOperation import com.nextcloud.client.account.User import com.nextcloud.client.network.ClientFactory import com.nextcloud.ui.fileInfo.model.GovernanceLabel @@ -34,13 +39,47 @@ class FileInfoViewModel @Inject constructor( private val _retentionLabels = MutableStateFlow?>(null) val retentionLabels: StateFlow?> = _retentionLabels + private val _currentSensitivityLabelId = MutableStateFlow(null) + val currentSensitivityLabelId: StateFlow = _currentSensitivityLabelId + + private var cachedFile: OCFile? = null + private var cachedUser: User? = null + fun load(file: OCFile, user: User) { + cachedFile = file + cachedUser = user viewModelScope.launch(Dispatchers.IO) { _sensitivityLabels.value = fetchSensitivityLabels(file, user) } viewModelScope.launch(Dispatchers.IO) { _retentionLabels.value = fetchRetentionLabels(file, user) } + viewModelScope.launch(Dispatchers.IO) { + _currentSensitivityLabelId.value = fetchEntityLabels(file, user)?.sensitivity?.id ?: "" + } + } + + fun setSensitivityLabel(labelId: String) { + val file = cachedFile ?: return + val user = cachedUser ?: return + viewModelScope.launch(Dispatchers.IO) { + val result = applyLabel(file, user, LabelType.SENSITIVITY, labelId) + if (result) { + _currentSensitivityLabelId.value = labelId + } + } + } + + fun removeSensitivityLabel() { + val labelId = _currentSensitivityLabelId.value?.takeIf { it.isNotEmpty() } ?: return + val file = cachedFile ?: return + val user = cachedUser ?: return + viewModelScope.launch(Dispatchers.IO) { + val result = removeLabel(file, user, LabelType.SENSITIVITY, labelId) + if (result) { + _currentSensitivityLabelId.value = "" + } + } } @Suppress("TooGenericExceptionCaught") @@ -63,9 +102,37 @@ class FileInfoViewModel @Inject constructor( emptyList() } - private fun SensitivityLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) + @Suppress("TooGenericExceptionCaught") + private fun fetchEntityLabels(file: OCFile, user: User): EntityLabels? = try { + val client = clientFactory.createNextcloudClient(user) + val result = GetEntityLabelsRemoteOperation(ENTITY_TYPE_FILES, file.localId.toString()).execute(client) + if (result.isSuccess) result.resultData else null + } catch (e: Exception) { + Log_OC.e(TAG, "Could not fetch entity labels", e) + null + } + + @Suppress("TooGenericExceptionCaught") + private fun applyLabel(file: OCFile, user: User, labelType: LabelType, labelId: String): Boolean = try { + val client = clientFactory.createNextcloudClient(user) + SetLabelRemoteOperation(ENTITY_TYPE_FILES, file.localId, labelType, labelId).execute(client).isSuccess + } catch (e: Exception) { + Log_OC.e(TAG, "Could not set label", e) + false + } + + @Suppress("TooGenericExceptionCaught") + private fun removeLabel(file: OCFile, user: User, labelType: LabelType, labelId: String): Boolean = try { + val client = clientFactory.createNextcloudClient(user) + RemoveLabelRemoteOperation(ENTITY_TYPE_FILES, file.localId, labelType, labelId).execute(client).isSuccess + } catch (e: Exception) { + Log_OC.e(TAG, "Could not remove label", e) + false + } + + private fun SensitivityLabelInfo.toGovernanceLabel() = GovernanceLabel(id, name, color) - private fun RetentionLabelInfo.toGovernanceLabel() = GovernanceLabel(name, color) + private fun RetentionLabelInfo.toGovernanceLabel() = GovernanceLabel(id, name, color) companion object { private val TAG = FileInfoViewModel::class.java.simpleName diff --git a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt index 56d694a6da99..5f980e4a4a88 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/GovernanceDetailInfo.kt @@ -46,8 +46,22 @@ class GovernanceDetailInfo( val labels = viewModel.sensitivityLabels.filterNotNull().first() if (labels.isEmpty()) { binding.sensitivityLabel.visibility = View.GONE - } else { - initDropdown(binding.sensitivityLabel, binding.sensitivityLabelAutoComplete, labels) + return@launch + } + val currentId = viewModel.currentSensitivityLabelId.filterNotNull().first() + val items = listOf(GovernanceLabel("", "", "")) + labels + val initialItem = items.find { it.id == currentId } ?: items.first() + initDropdown( + textInputLayout = binding.sensitivityLabel, + autoComplete = binding.sensitivityLabelAutoComplete, + items = items, + initialItem = initialItem + ) { label -> + if (label.id.isEmpty()) { + viewModel.removeSensitivityLabel() + } else { + viewModel.setSensitivityLabel(label.id) + } } } } @@ -57,27 +71,34 @@ class GovernanceDetailInfo( val labels = viewModel.retentionLabels.filterNotNull().first() if (labels.isEmpty()) { binding.fileRetentionLabel.visibility = View.GONE - } else { - initDropdown(binding.fileRetentionLabel, binding.fileRetentionAutoComplete, labels) + return@launch } + initDropdown( + textInputLayout = binding.fileRetentionLabel, + autoComplete = binding.fileRetentionAutoComplete, + items = labels + ) } } private fun initDropdown( textInputLayout: TextInputLayout, autoComplete: MaterialAutoCompleteTextView, - items: List + items: List, + initialItem: GovernanceLabel? = items.firstOrNull(), + onSelect: (GovernanceLabel) -> Unit = {} ) { textInputLayout.visibility = View.VISIBLE viewThemeUtils.material.colorTextInputLayout(textInputLayout) viewThemeUtils.files.themeAutoCompleteTextView(autoComplete) autoComplete.setAdapter(buildAdapter(items)) - - items.firstOrNull()?.let { applySelection(autoComplete, it) } + initialItem?.let { applySelection(autoComplete, it) } autoComplete.setOnItemClickListener { _, _, position, _ -> - applySelection(autoComplete, items[position]) + val item = items[position] + applySelection(autoComplete, item) + onSelect(item) } } @@ -87,7 +108,8 @@ class GovernanceDetailInfo( val view = super.getView(position, convertView, parent) as TextView getItem(position)?.let { item -> view.text = item.text - view.setCompoundDrawablesWithIntrinsicBounds(colorDot(item.color), null, null, null) + val dot = if (item.color.isNotEmpty()) colorDot(item.color) else null + view.setCompoundDrawablesWithIntrinsicBounds(dot, null, null, null) } return view } @@ -95,8 +117,12 @@ class GovernanceDetailInfo( private fun applySelection(autoComplete: MaterialAutoCompleteTextView, item: GovernanceLabel) { autoComplete.setText(item.text, false) - autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(colorDot(item.color), null, null, null) - autoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + if (item.color.isNotEmpty()) { + autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(colorDot(item.color), null, null, null) + autoComplete.compoundDrawablePadding = fragment.resources.getDimensionPixelSize(R.dimen.standard_padding) + } else { + autoComplete.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null) + } } private fun colorDot(color: String): Drawable { 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 index b8f1f4749cc5..10c65aeaaf3a 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/model/GovernanceLabel.kt @@ -7,4 +7,6 @@ package com.nextcloud.ui.fileInfo.model -data class GovernanceLabel(val text: String, val color: String) +data class GovernanceLabel(val id: String, val text: String, val color: String) { + override fun toString(): String = text +}