diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadLocalDeletionWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadLocalDeletionWorker.kt index 778d290ce594..2d2844f01cb8 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadLocalDeletionWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadLocalDeletionWorker.kt @@ -13,6 +13,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.jobs.notification.WorkerNotificationManager +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.SyncedFolderProvider @@ -20,7 +21,6 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.upload.DeleteUploadedFileOperation import com.owncloud.android.ui.notifications.NotificationUtils -import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.FileUtil import com.owncloud.android.utils.theme.ViewThemeUtils import java.io.File @@ -122,7 +122,7 @@ class AutoUploadLocalDeletionWorker( ): Notification { val notificationContent = context.getString( R.string.autoupload_delete_uploaded_notif_ended_content, - DisplayUtils.bytesToHumanReadable(spaceFreed), + HumanReadableFormatter.formatBytes(spaceFreed), context.resources.getQuantityString( R.plurals.autoupload_delete_uploaded_notif_ended_content_files, filesRemoved.toInt(), diff --git a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt index 2eaa40b2b5fe..65c49a651623 100644 --- a/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt +++ b/app/src/main/java/com/nextcloud/client/player/ui/audio/AudioFileFragment.kt @@ -20,6 +20,7 @@ import com.nextcloud.client.player.model.state.PlaybackItemMetadata import com.nextcloud.client.player.model.state.PlaybackState import com.nextcloud.client.player.util.PlayerUtil.getPlaybackFile import com.nextcloud.client.player.util.PlayerUtil.putPlaybackFile +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.R import com.owncloud.android.databinding.PlayerAudioFileFragmentBinding import com.owncloud.android.utils.DisplayUtils @@ -132,7 +133,7 @@ open class AudioFileFragment : } private fun PlaybackFile.getDetailsText(): String = listOfNotNull( - contentLength.takeIf { it > 0 }?.let { DisplayUtils.bytesToHumanReadable(it) }, + contentLength.takeIf { it > 0 }?.let { HumanReadableFormatter.formatBytes(it) }, lastModified.takeIf { it > 0 }?.let(::getLastModifiedText) ).joinToString(DETAILS_SEPARATOR) diff --git a/app/src/main/java/com/nextcloud/ui/ChooseStorageLocationDialogFragment.kt b/app/src/main/java/com/nextcloud/ui/ChooseStorageLocationDialogFragment.kt index 3361e5dab7c9..bab326a269dd 100644 --- a/app/src/main/java/com/nextcloud/ui/ChooseStorageLocationDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/ui/ChooseStorageLocationDialogFragment.kt @@ -18,6 +18,7 @@ import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.client.di.Injectable import com.nextcloud.client.preferences.AppPreferencesImpl +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.databinding.DialogDataStorageLocationBinding @@ -27,7 +28,6 @@ import com.owncloud.android.datastorage.StoragePoint.PrivacyType import com.owncloud.android.datastorage.StoragePoint.StorageType import com.owncloud.android.ui.dialog.extensions.themeButtons import com.owncloud.android.ui.model.ExtendedSettingsActivityDialog -import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.theme.ViewThemeUtils import java.io.File import javax.inject.Inject @@ -121,8 +121,8 @@ class ChooseStorageLocationDialogFragment : return String.format( getString(R.string.file_migration_free_space), typeString, - DisplayUtils.bytesToHumanReadable(usedSpace), - DisplayUtils.bytesToHumanReadable(totalSpace) + HumanReadableFormatter.formatBytes(usedSpace), + HumanReadableFormatter.formatBytes(totalSpace) ) } ?: typeString } 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 b23bc1eb1a8f..11eced485f55 100644 --- a/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt +++ b/app/src/main/java/com/nextcloud/ui/fileInfo/ImageDetailInfo.kt @@ -20,13 +20,13 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.NominatimClient import com.nextcloud.client.utils.IntentUtil import com.nextcloud.ui.fileInfo.model.ImageMetadata +import com.nextcloud.utils.HumanReadableFormatter import com.nextcloud.utils.extensions.getSmallThumbnail 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.utils.BitmapUtils -import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -181,7 +181,7 @@ class ImageDetailInfo(private val fragment: FileInfoFragment, private val viewTh } fun gatherMetadata(file: OCFile): ImageMetadata { - val fileSize = DisplayUtils.bytesToHumanReadable(file.fileLength) + val fileSize = HumanReadableFormatter.formatBytes(file.fileLength) val timestamp = maxOf(file.modificationTimestamp, file.creationTimestamp) return if (file.isDown) { gatherLocalMetadata(file, fileSize, timestamp) diff --git a/app/src/main/java/com/nextcloud/utils/ByteUnit.kt b/app/src/main/java/com/nextcloud/utils/ByteUnit.kt new file mode 100644 index 000000000000..500ae027858b --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/ByteUnit.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +enum class ByteUnit(val suffix: String, val decimals: Int) { + BYTES("B", 0), + KILOBYTES("KB", 0), + MEGABYTES("MB", 1), + GIGABYTES("GB", 1), + TERABYTES("TB", 1), + PETABYTES("PB", 2), + EXABYTES("EB", 2) +} diff --git a/app/src/main/java/com/nextcloud/utils/HumanReadableFormatter.kt b/app/src/main/java/com/nextcloud/utils/HumanReadableFormatter.kt new file mode 100644 index 000000000000..c61822f19725 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/HumanReadableFormatter.kt @@ -0,0 +1,38 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.owncloud.android.MainApp +import com.owncloud.android.R +import java.text.DateFormat +import java.util.Date +import java.util.Locale + +object HumanReadableFormatter { + + private const val BITS_PER_UNIT = 10 + + @JvmStatic + fun formatBytes(bytes: Long): String { + if (bytes < 0) { + return MainApp.string(R.string.common_pending) + } + + val highestSetBit = Long.SIZE_BITS - 1 - bytes.countLeadingZeroBits() + val unitIndex = (highestSetBit / BITS_PER_UNIT).coerceIn(0, ByteUnit.entries.lastIndex) + val unit = ByteUnit.entries[unitIndex] + val size = bytes.toDouble() / (1L shl (BITS_PER_UNIT * unitIndex)) + + val pattern = "%.${unit.decimals}f" + val formattedSize = String.format(Locale.ROOT, pattern, size) + return "$formattedSize ${unit.suffix}" + } + + @JvmStatic + fun formatDateTime(milliseconds: Long): String = DateFormat.getDateTimeInstance().format(Date(milliseconds)) +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt index 138af0d0060c..21ee4888cecd 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -9,18 +9,18 @@ package com.nextcloud.utils.extensions import android.graphics.Bitmap import androidx.exifinterface.media.ExifInterface +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.model.ServerFileInterface -import com.owncloud.android.utils.DisplayUtils import java.io.File import java.nio.file.Path private const val TAG = "FileExtensions" fun OCFile?.logFileSize(tag: String) { - val size = DisplayUtils.bytesToHumanReadable(this?.fileLength ?: -1) + val size = HumanReadableFormatter.formatBytes(this?.fileLength ?: -1) val rawByte = this?.fileLength ?: -1 Log_OC.d(tag, "onSaveInstanceState: $size, raw byte $rawByte") } @@ -48,7 +48,7 @@ fun File?.getSmallThumbnail(): Bitmap? = ThumbnailsCacheManager.getBitmapFromDis fun File?.getSmallThumbnailKey(): String = ThumbnailsCacheManager.PREFIX_THUMBNAIL + hashCode() fun File?.logFileSize(tag: String) { - val size = DisplayUtils.bytesToHumanReadable(this?.length() ?: -1) + val size = HumanReadableFormatter.formatBytes(this?.length() ?: -1) val rawByte = this?.length() ?: -1 Log_OC.d(tag, "onSaveInstanceState: $size, raw byte $rawByte") } diff --git a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityAndVersionListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityAndVersionListAdapter.kt index cd2c24c65ce3..e0e531f1f337 100644 --- a/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityAndVersionListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/activities/adapter/ActivityAndVersionListAdapter.kt @@ -14,12 +14,12 @@ import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.RecyclerView import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.common.NextcloudClient +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.databinding.VersionListItemBinding import com.owncloud.android.lib.resources.activities.model.Activity import com.owncloud.android.lib.resources.files.model.FileVersion import com.owncloud.android.ui.interfaces.ActivityListInterface import com.owncloud.android.ui.interfaces.VersionListInterface -import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.theme.ViewThemeUtils import java.util.Date @@ -71,7 +71,7 @@ class ActivityAndVersionListAdapter( override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (holder is VersionViewHolder) { val fileVersion = values[position] as FileVersion - holder.binding.size.text = DisplayUtils.bytesToHumanReadable(fileVersion.fileLength) + holder.binding.size.text = HumanReadableFormatter.formatBytes(fileVersion.fileLength) holder.binding.time.text = DateFormat.format("HH:mm", Date(fileVersion.modifiedTimestamp).time) holder.binding.restore.setOnClickListener { versionListInterface.onRestoreClicked(fileVersion) } } else { diff --git a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java index 5430460c5000..56c21cea670a 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java +++ b/app/src/main/java/com/owncloud/android/ui/activity/DrawerActivity.java @@ -65,6 +65,7 @@ import com.nextcloud.ui.composeActivity.ComposeActivity; import com.nextcloud.ui.composeActivity.ComposeDestination; import com.nextcloud.utils.GlideHelper; +import com.nextcloud.utils.HumanReadableFormatter; import com.nextcloud.utils.LinkHelper; import com.nextcloud.utils.SnackbarUtil; import com.nextcloud.utils.extensions.ActivityExtensionsKt; @@ -102,7 +103,6 @@ import com.owncloud.android.ui.fragment.albums.AlbumsFragment; import com.owncloud.android.ui.navigation.NavigatorActivity; import com.owncloud.android.ui.navigation.NavigatorScreen; -import com.owncloud.android.ui.trashbin.TrashbinFragment; import com.owncloud.android.utils.BitmapUtils; import com.owncloud.android.utils.DisplayUtils; import com.owncloud.android.utils.DrawableUtil; @@ -940,12 +940,12 @@ private void setQuotaInformation(long usedSpace, long totalSpace, int relative, if (GetUserInfoRemoteOperation.SPACE_UNLIMITED == quotaValue) { mQuotaTextPercentage.setText(String.format( getString(R.string.drawer_quota_unlimited), - DisplayUtils.bytesToHumanReadable(usedSpace))); + HumanReadableFormatter.formatBytes(usedSpace))); } else { mQuotaTextPercentage.setText(String.format( getString(R.string.drawer_quota), - DisplayUtils.bytesToHumanReadable(usedSpace), - DisplayUtils.bytesToHumanReadable(totalSpace))); + HumanReadableFormatter.formatBytes(usedSpace), + HumanReadableFormatter.formatBytes(totalSpace))); } mQuotaProgressBar.setProgress(relative); diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ShareActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ShareActivity.kt index b60f3997eb6d..a0df0673cf4c 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ShareActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ShareActivity.kt @@ -18,6 +18,7 @@ import androidx.lifecycle.lifecycleScope import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.client.account.User import com.nextcloud.client.di.Injectable +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.R import com.owncloud.android.databinding.ShareActivityBinding import com.owncloud.android.datamodel.OCFile @@ -32,7 +33,6 @@ import com.owncloud.android.lib.resources.shares.ShareType import com.owncloud.android.operations.GetSharesForFileOperation import com.owncloud.android.ui.fragment.FileDetailSharingFragment import com.owncloud.android.ui.fragment.FileDetailsSharingProcessFragment -import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import com.nextcloud.utils.thumbnail.ThumbnailGenerator import kotlinx.coroutines.Dispatchers @@ -122,7 +122,7 @@ class ShareActivity : with(binding) { shareFileName.text = getString(R.string.share_file, file.fileName) viewThemeUtils.platform.colorViewBackground(shareHeaderDivider) - shareFileSize.text = DisplayUtils.bytesToHumanReadable(file.fileLength) + shareFileSize.text = HumanReadableFormatter.formatBytes(file.fileLength) } } @@ -150,7 +150,7 @@ class ShareActivity : val length = (result.data.first() as? RemoteFile)?.length ?: return@launch file.fileLength = length withContext(Dispatchers.Main) { - binding.shareFileSize.text = DisplayUtils.bytesToHumanReadable(length) + binding.shareFileSize.text = HumanReadableFormatter.formatBytes(length) } } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncViewHolder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncViewHolder.kt index a35f362bdec6..a0de99f64027 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncViewHolder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/InternalTwoWaySyncViewHolder.kt @@ -10,6 +10,7 @@ package com.owncloud.android.ui.adapter import android.content.Context import android.view.View import androidx.recyclerview.widget.RecyclerView +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.R import com.owncloud.android.databinding.InternalTwoWaySyncViewHolderBinding import com.owncloud.android.datamodel.FileDataStorageManager @@ -25,7 +26,7 @@ class InternalTwoWaySyncViewHolder(val binding: InternalTwoWaySyncViewHolderBind internalTwoWaySyncAdapter: InternalTwoWaySyncAdapter ) { binding.run { - size.text = DisplayUtils.bytesToHumanReadable(folder.fileLength) + size.text = HumanReadableFormatter.formatBytes(folder.fileLength) name.text = folder.decryptedFileName if (folder.internalFolderSyncResult.isEmpty()) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java index b65ecf532d4e..bce25acf11e8 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/OCFileListAdapter.java @@ -34,6 +34,7 @@ import com.nextcloud.client.jobs.upload.FileUploadHelper; import com.nextcloud.client.preferences.AppPreferences; import com.nextcloud.model.OfflineOperationType; +import com.nextcloud.utils.HumanReadableFormatter; import com.nextcloud.utils.e2ee.E2EVersionHelper; import com.nextcloud.utils.extensions.ImageViewExtensionsKt; import com.nextcloud.utils.extensions.ViewExtensionsKt; @@ -741,7 +742,7 @@ private void prepareFileSize(ListItemViewHolder holder, OCFile file, long size) private String getFileSizeText(OCFile file, long size) { if (!file.isOfflineOperation()) { - return DisplayUtils.bytesToHumanReadable(size); + return HumanReadableFormatter.formatBytes(size); } OfflineOperationEntity entity = mStorageManager.getOfflineEntityFromOCFile(file); @@ -1044,7 +1045,7 @@ public String getPopupText(@NonNull View view, int position) { Date date = new Date(milliseconds); return dateFormat.format(date); case SIZE: - return DisplayUtils.bytesToHumanReadable(file.getFileLength()); + return HumanReadableFormatter.formatBytes(file.getFileLength()); default: Log_OC.d(TAG, "getPopupText: Unsupported sort order: " + sortOrder.getType()); return ""; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt index cbac02172939..e01ca8d26e25 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/ReceiveExternalFilesAdapter.kt @@ -15,6 +15,7 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.recyclerview.widget.RecyclerView import com.nextcloud.client.account.User +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.databinding.UploaderListItemLayoutBinding import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile @@ -27,7 +28,6 @@ import com.owncloud.android.datamodel.ThumbnailsCacheManager.ThumbnailGeneration import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils -import java.util.Objects @Suppress("LongParameterList") class ReceiveExternalFilesAdapter( @@ -83,7 +83,7 @@ class ReceiveExternalFilesAdapter( viewHolder.binding.lastMod.text = DisplayUtils.getRelativeTimestamp(context, file.modificationTimestamp) if (!file.isFolder) { - viewHolder.binding.fileSize.text = DisplayUtils.bytesToHumanReadable(file.fileLength) + viewHolder.binding.fileSize.text = HumanReadableFormatter.formatBytes(file.fileLength) } viewHolder.binding.fileSize.visibility = if (file.isFolder) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java b/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java index 3d1595be438f..83e24b76791e 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java +++ b/app/src/main/java/com/owncloud/android/ui/adapter/TrashbinListAdapter.java @@ -20,6 +20,7 @@ import com.nextcloud.android.common.ui.theme.utils.ColorRole; import com.nextcloud.client.account.User; import com.nextcloud.client.preferences.AppPreferences; +import com.nextcloud.utils.HumanReadableFormatter; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.extensions.ViewExtensionsKt; import com.nextcloud.utils.thumbnail.VideoOverlayGenerator; @@ -137,7 +138,7 @@ public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int positi trashbinFileViewHolder.binding.Filename.setText(file.getFileName()); // fileSize - trashbinFileViewHolder.binding.fileSize.setText(DisplayUtils.bytesToHumanReadable(file.getFileLength())); + trashbinFileViewHolder.binding.fileSize.setText(HumanReadableFormatter.formatBytes(file.getFileLength())); // originalLocation String location; diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/localFileList/LocalFileListItemBinder.kt b/app/src/main/java/com/owncloud/android/ui/adapter/localFileList/LocalFileListItemBinder.kt index e52e0790d957..929e7d5b1479 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/localFileList/LocalFileListItemBinder.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/localFileList/LocalFileListItemBinder.kt @@ -10,6 +10,7 @@ import android.app.Activity import android.view.View import androidx.core.content.ContextCompat import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.R import com.owncloud.android.ui.interfaces.LocalFileListFragmentInterface import com.owncloud.android.utils.DisplayUtils @@ -76,7 +77,7 @@ internal class LocalFileListItemBinder( } else { holder.fileSize.visibility = View.VISIBLE holder.fileSeparator.visibility = View.VISIBLE - holder.fileSize.text = DisplayUtils.bytesToHumanReadable(file.length()) + holder.fileSize.text = HumanReadableFormatter.formatBytes(file.length()) } holder.lastModification.text = DisplayUtils.getRelativeTimestamp(activity, file.lastModified()) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt index 49eaa86821b8..ed41ac446d93 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt @@ -24,6 +24,7 @@ import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.network.ConnectivityService +import com.nextcloud.utils.HumanReadableFormatter import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.extensions.getStatusText import com.nextcloud.utils.extensions.isLastResultConflictError @@ -276,7 +277,7 @@ class UploadListAdapter( fileSizeFormat = "%s, " } - val fileSizeInBytes = DisplayUtils.bytesToHumanReadable(item.fileSize) + val fileSizeInBytes = HumanReadableFormatter.formatBytes(item.fileSize) val uploadFileSize = String.format(fileSizeFormat, fileSizeInBytes) holder.binding.uploadFileSize.text = uploadFileSize } else { diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/SyncFileNotEnoughSpaceDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/SyncFileNotEnoughSpaceDialogFragment.kt index cca15c8cdcd4..2344680c594f 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/SyncFileNotEnoughSpaceDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/SyncFileNotEnoughSpaceDialogFragment.kt @@ -11,11 +11,11 @@ import android.content.Intent import android.os.Bundle import android.os.storage.StorageManager import androidx.activity.result.contract.ActivityResultContracts +import com.nextcloud.utils.HumanReadableFormatter import com.nextcloud.utils.extensions.getParcelableArgument import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.dialog.ConfirmationDialogFragment.ConfirmationDialogFragmentListener -import com.owncloud.android.utils.DisplayUtils class SyncFileNotEnoughSpaceDialogFragment : ConfirmationDialogFragment(), @@ -66,8 +66,8 @@ class SyncFileNotEnoughSpaceDialogFragment : ARG_MESSAGE_ARGUMENTS, arrayOf( file.fileName, - DisplayUtils.bytesToHumanReadable(file.fileLength), - DisplayUtils.bytesToHumanReadable(availableDeviceSpace) + HumanReadableFormatter.formatBytes(file.fileLength), + HumanReadableFormatter.formatBytes(availableDeviceSpace) ) ) putParcelable(ARG_PASSED_FILE, file) diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt index 9a6fd6a32a98..9f5f8e4d94ab 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictResolveDialogFactory.kt @@ -12,6 +12,7 @@ import android.os.Bundle import androidx.annotation.StringRes import com.nextcloud.client.account.User import com.nextcloud.client.database.entity.OfflineOperationEntity +import com.nextcloud.utils.HumanReadableFormatter import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile import com.owncloud.android.ui.dialog.conflict.model.ConflictDialogData @@ -75,7 +76,7 @@ object ConflictResolveDialogFactory { ConflictFileData( title = getString(titleId), timestamp = DisplayUtils.getRelativeTimestamp(this, timestamp).toString(), - fileSize = DisplayUtils.bytesToHumanReadable(fileLength) + fileSize = HumanReadableFormatter.formatBytes(fileLength) ) private fun createDialog(type: ConflictDialogType, putFiles: Bundle.() -> Unit): ConflictsResolveDialog = 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 e5d3e9e686b0..734d02d76557 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 @@ -35,6 +35,7 @@ import com.nextcloud.ui.fileactions.FileAction; import com.nextcloud.ui.fileactions.FileActionsBottomSheet; import com.nextcloud.ui.tags.TagManagementBottomSheet; +import com.nextcloud.utils.HumanReadableFormatter; import com.nextcloud.utils.MenuUtils; import com.nextcloud.utils.SnackbarUtil; import com.nextcloud.utils.extensions.BundleExtensionsKt; @@ -609,7 +610,7 @@ public void updateFileDetails(boolean transferring, boolean refresh) { } else { binding.filename.setVisibility(View.GONE); } - binding.size.setText(DisplayUtils.bytesToHumanReadable(file.getFileLength())); + binding.size.setText(HumanReadableFormatter.formatBytes(file.getFileLength())); boolean showDetailedTimestamp = preferences.isShowDetailedTimestampEnabled(); setFileModificationTimestamp(file, showDetailedTimestamp); @@ -670,7 +671,7 @@ public void onDownloadProgress(FileDownloadProgressEvent event) { private void setFileModificationTimestamp(OCFile file, boolean showDetailedTimestamp) { if (showDetailedTimestamp) { - binding.lastModificationTimestamp.setText(DisplayUtils.unixTimeToHumanReadable(file.getModificationTimestamp())); + binding.lastModificationTimestamp.setText(HumanReadableFormatter.formatDateTime(file.getModificationTimestamp())); } else { binding.lastModificationTimestamp.setText(DisplayUtils.getRelativeTimestamp(getContext(), file.getModificationTimestamp())); diff --git a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java index 43dc3b796afd..433ab2124305 100644 --- a/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/DisplayUtils.java @@ -37,6 +37,7 @@ import android.view.View; import com.nextcloud.client.account.User; +import com.nextcloud.utils.HumanReadableFormatter; import com.owncloud.android.MainApp; import com.owncloud.android.R; import com.owncloud.android.datamodel.ThumbnailsCacheManager; @@ -52,13 +53,10 @@ import java.io.InputStreamReader; import java.lang.reflect.Constructor; import java.lang.reflect.Method; -import java.math.BigDecimal; -import java.math.RoundingMode; import java.net.IDN; import java.nio.charset.Charset; import java.text.DateFormat; import java.text.SimpleDateFormat; -import java.util.Date; import java.util.Locale; import java.util.TimeZone; @@ -84,14 +82,9 @@ public final class DisplayUtils { private static final String TAG = DisplayUtils.class.getSimpleName(); - private static final String[] sizeSuffixes = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}; - private static final int[] sizeScales = {0, 0, 1, 1, 1, 2, 2, 2, 2}; - private static final String HTTP_PROTOCOL = "http://"; private static final String HTTPS_PROTOCOL = "https://"; private static final String TWITTER_HANDLE_PREFIX = "@"; - private static final int BYTE_SIZE_DIVIDER = 1024; - private static final double BYTE_SIZE_DIVIDER_DOUBLE = 1024.0; private static final int DATE_TIME_PARTS_SIZE = 2; public static final String MONTH_YEAR_PATTERN = "MMMM yyyy"; public static final String MONTH_PATTERN = "MMMM"; @@ -101,45 +94,6 @@ private DisplayUtils() { // utility class -> private constructor } - /** - * Converts the file size in bytes to human readable output. - * - * - * @param bytes Input file size - * @return something readable like "12 MB", {@link com.owncloud.android.R.string#common_pending} for negative - * byte values - */ - public static String bytesToHumanReadable(long bytes) { - if (bytes < 0) { - return MainApp.string(R.string.common_pending); - } else { - double result = bytes; - int suffixIndex = 0; - while (result > BYTE_SIZE_DIVIDER && suffixIndex < sizeSuffixes.length) { - result /= BYTE_SIZE_DIVIDER_DOUBLE; - suffixIndex++; - } - - return new BigDecimal(String.valueOf(result)).setScale( - sizeScales[suffixIndex], RoundingMode.HALF_UP) + " " + sizeSuffixes[suffixIndex]; - } - } - - /** - * Converts Unix time to human readable format - * - * @param milliseconds that have passed since 01/01/1970 - * @return The human readable time for the users locale - */ - public static String unixTimeToHumanReadable(long milliseconds) { - Date date = new Date(milliseconds); - DateFormat df = DateFormat.getDateTimeInstance(); - return df.format(date); - } - /** * beautifies a given URL by removing any http/https protocol prefix. * @@ -270,7 +224,7 @@ public static CharSequence getRelativeDateTimeString(Context c, // in Future if (!showFuture && time > System.currentTimeMillis()) { - return DisplayUtils.unixTimeToHumanReadable(time); + return HumanReadableFormatter.formatDateTime(time); } // < 60 seconds -> seconds ago long diff = System.currentTimeMillis() - time; diff --git a/app/src/test/java/com/nextcloud/utils/HumanReadableFormatterTest.kt b/app/src/test/java/com/nextcloud/utils/HumanReadableFormatterTest.kt new file mode 100644 index 000000000000..6c9cf3d04aec --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/HumanReadableFormatterTest.kt @@ -0,0 +1,126 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils + +import com.owncloud.android.MainApp +import com.owncloud.android.R +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Test +import org.mockito.MockedStatic +import org.mockito.Mockito +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +class HumanReadableFormatterTest { + + private val pendingLabel = "Pending" + + private lateinit var mainApp: MockedStatic + private lateinit var defaultLocale: Locale + private lateinit var defaultTimeZone: TimeZone + + @Before + fun setUp() { + mainApp = Mockito.mockStatic(MainApp::class.java) + mainApp.`when` { MainApp.string(R.string.common_pending) }.thenReturn(pendingLabel) + + defaultLocale = Locale.getDefault() + defaultTimeZone = TimeZone.getDefault() + Locale.setDefault(Locale.US) + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @After + fun tearDown() { + mainApp.close() + Locale.setDefault(defaultLocale) + TimeZone.setDefault(defaultTimeZone) + } + + @Test + fun `sizes below one kilobyte are shown as whole bytes`() { + assertEquals("0 B", HumanReadableFormatter.formatBytes(0)) + assertEquals("1 B", HumanReadableFormatter.formatBytes(1)) + assertEquals("512 B", HumanReadableFormatter.formatBytes(512)) + assertEquals("1023 B", HumanReadableFormatter.formatBytes(1023)) + } + + @Test + fun `each unit starts at exactly 1024 of the previous one`() { + assertEquals("1 KB", HumanReadableFormatter.formatBytes(1L shl 10)) + assertEquals("1.0 MB", HumanReadableFormatter.formatBytes(1L shl 20)) + assertEquals("1.0 GB", HumanReadableFormatter.formatBytes(1L shl 30)) + assertEquals("1.0 TB", HumanReadableFormatter.formatBytes(1L shl 40)) + assertEquals("1.00 PB", HumanReadableFormatter.formatBytes(1L shl 50)) + assertEquals("1.00 EB", HumanReadableFormatter.formatBytes(1L shl 60)) + } + + @Test + fun `kilobytes are rounded to whole numbers`() { + assertEquals("1 KB", HumanReadableFormatter.formatBytes(1025)) + assertEquals("2 KB", HumanReadableFormatter.formatBytes(1536)) + } + + @Test + fun `megabytes up to terabytes keep one decimal`() { + assertEquals("1.0 MB", HumanReadableFormatter.formatBytes(1024L * 1024 + 1)) + assertEquals("1.5 GB", HumanReadableFormatter.formatBytes(1610612736)) + assertEquals("5.0 GB", HumanReadableFormatter.formatBytes(5L * 1024 * 1024 * 1024)) + } + + @Test + fun `petabytes and above keep two decimals`() { + assertEquals("1.00 PB", HumanReadableFormatter.formatBytes((1L shl 50) + 1)) + } + + @Test + fun `the largest possible size stays within the known units`() { + assertEquals("8.00 EB", HumanReadableFormatter.formatBytes(Long.MAX_VALUE)) + } + + @Test + fun `a size is written with a decimal point in every locale`() { + Locale.setDefault(Locale.GERMANY) + assertEquals("1.5 GB", HumanReadableFormatter.formatBytes(1610612736)) + + Locale.setDefault(Locale.forLanguageTag("ar-EG")) + assertEquals("1.5 GB", HumanReadableFormatter.formatBytes(1610612736)) + } + + @Test + fun `a negative size is pending`() { + assertEquals(pendingLabel, HumanReadableFormatter.formatBytes(-1)) + assertEquals(pendingLabel, HumanReadableFormatter.formatBytes(Long.MIN_VALUE)) + } + + @Test + fun `a timestamp keeps its date and time when read back`() { + val timestamp = 1790000000000 + val formatted = HumanReadableFormatter.formatDateTime(timestamp) + + val parsed = DateFormat.getDateTimeInstance().parse(formatted) + + assertEquals(Date(timestamp), parsed) + } + + @Test + fun `a timestamp is formatted for the current locale`() { + val timestamp = 1790000000000 + + val american = HumanReadableFormatter.formatDateTime(timestamp) + Locale.setDefault(Locale.GERMANY) + val german = HumanReadableFormatter.formatDateTime(timestamp) + + assertNotEquals(american, german) + } +}