From 93609106b08b8913badd10cdca03a034d6f67019 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 2 Oct 2024 09:49:28 -0400 Subject: [PATCH] Update restore progress banner UI/UX and job behavior. --- .../backup/v2/ui/BackupAlertBottomSheet.kt | 6 +- .../backup/v2/ui/BackupsIconColors.kt | 29 +-- .../backup/v2/ui/CreateBackupBottomSheet.kt | 6 +- .../backup/v2/ui/status/BackupStatus.kt | 196 +++++++++++------- .../MessageBackupsTypeSelectionScreen.kt | 4 +- .../banners/MediaRestoreProgressBanner.kt | 121 ++++++++--- .../UpgradeToEnableOptimizedStorageSheet.kt | 6 +- .../ConversationListFragment.java | 12 +- .../impl/BatteryNotLowConstraint.kt | 39 ++++ ...gAndBatteryIsNotLowConstraintObserver.java | 85 ++++++++ .../jobmanager/impl/ChargingConstraint.java | 2 +- .../impl/ChargingConstraintObserver.java | 62 ------ .../jobmanager/impl/WifiConstraint.kt | 7 +- .../securesms/jobs/JobManagerFactories.java | 6 +- .../securesms/jobs/RestoreAttachmentJob.kt | 31 +-- .../securesms/keyvalue/BackupValues.kt | 7 +- .../keyvalue/SignalStoreValueDelegates.kt | 12 ++ .../res/drawable/symbol_backup_error_24.xml | 15 ++ app/src/main/res/values/strings.xml | 18 +- .../org/signal/core/util/ByteExtensions.kt | 56 +++-- 20 files changed, 491 insertions(+), 229 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/BatteryNotLowConstraint.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingAndBatteryIsNotLowConstraintObserver.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraintObserver.java create mode 100644 app/src/main/res/drawable/symbol_backup_error_24.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt index 87f6406ad7..1c5edbb589 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -35,7 +36,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import kotlinx.parcelize.Parcelize import org.signal.core.ui.BottomSheets import org.signal.core.ui.Buttons -import org.signal.core.ui.Icons import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview import org.thoughtcrime.securesms.R @@ -144,10 +144,10 @@ private fun BackupAlertSheetContent( Spacer(modifier = Modifier.size(26.dp)) val iconColors = rememberBackupsIconColors(backupAlert = backupAlert) - Icons.BrushedForeground( + Icon( painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [message-backups] final asset contentDescription = null, - foregroundBrush = iconColors.foreground, + tint = iconColors.foreground, modifier = Modifier .size(88.dp) .background(color = iconColors.background, shape = CircleShape) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupsIconColors.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupsIconColors.kt index d2f69ffd41..059a12a16c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupsIconColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupsIconColors.kt @@ -7,39 +7,32 @@ package org.thoughtcrime.securesms.backup.v2.ui import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor sealed interface BackupsIconColors { @get:Composable - val foreground: Brush + val foreground: Color @get:Composable val background: Color data object Normal : BackupsIconColors { - override val foreground: Brush - @Composable get() = remember { - Brush.linearGradient( - colors = listOf(Color(0xFF316ED0), Color(0xFF558BE2)), - start = Offset(x = 0f, y = Float.POSITIVE_INFINITY), - end = Offset(x = Float.POSITIVE_INFINITY, y = 0f) - ) - } - + override val foreground: Color @Composable get() = MaterialTheme.colorScheme.onSurface override val background: Color @Composable get() = MaterialTheme.colorScheme.primaryContainer } - object Warning : BackupsIconColors { - override val foreground: Brush @Composable get() = SolidColor(Color(0xFFC86600)) + data object Success : BackupsIconColors { + override val foreground: Color @Composable get() = MaterialTheme.colorScheme.primary + override val background: Color @Composable get() = MaterialTheme.colorScheme.primaryContainer + } + + data object Warning : BackupsIconColors { + override val foreground: Color @Composable get() = Color(0xFFFF9500) override val background: Color @Composable get() = Color(0xFFF9E4B6) } - object Error : BackupsIconColors { - override val foreground: Brush @Composable get() = SolidColor(MaterialTheme.colorScheme.error) + data object Error : BackupsIconColors { + override val foreground: Color @Composable get() = MaterialTheme.colorScheme.error override val background: Color @Composable get() = Color(0xFFFFD9D9) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt index 6531d04a50..ac7607d35a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/CreateBackupBottomSheet.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -29,7 +30,6 @@ import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import org.signal.core.ui.BottomSheets import org.signal.core.ui.Buttons -import org.signal.core.ui.Icons import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview import org.thoughtcrime.securesms.R @@ -90,9 +90,9 @@ private fun CreateBackupBottomSheetContent( ) { BottomSheets.Handle() - Icons.BrushedForeground( + Icon( painter = painterResource(id = R.drawable.symbol_backup_light), - foregroundBrush = BackupsIconColors.Normal.foreground, + tint = BackupsIconColors.Normal.foreground, contentDescription = null, modifier = Modifier .padding(top = 18.dp, bottom = 11.dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatus.kt index 8f51eaec85..43122837f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatus.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatus.kt @@ -7,31 +7,41 @@ package org.thoughtcrime.securesms.backup.v2.ui.status import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.signal.core.ui.Buttons -import org.signal.core.ui.Icons import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview +import org.signal.core.util.ByteSize +import org.signal.core.util.bytes +import org.signal.core.util.kibiBytes +import org.signal.core.util.mebiBytes import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors import kotlin.math.max @@ -43,10 +53,12 @@ private const val NONE = -1 * Displays a "heads up" widget containing information about the current * status of the user's backup. */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun BackupStatus( data: BackupStatusData, - onActionClick: () -> Unit = {}, + onSkipClick: () -> Unit = {}, + onDismissClick: () -> Unit = {}, contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp) ) { Row( @@ -55,56 +67,81 @@ fun BackupStatus( .padding(contentPadding) .border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp)) .fillMaxWidth() - .padding(14.dp) + .defaultMinSize(minHeight = 48.dp) + .padding(12.dp) ) { - val foreground: Brush = data.iconColors.foreground - Icons.BrushedForeground( + Icon( painter = painterResource(id = data.iconRes), contentDescription = null, - foregroundBrush = foreground, + tint = data.iconColors.foreground, modifier = Modifier - .background(color = data.iconColors.background, shape = CircleShape) - .padding(8.dp) + .padding(start = 4.dp) + .size(24.dp) ) - Column( + FlowRow( + horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.Center, modifier = Modifier .padding(start = 12.dp) .weight(1f) ) { Text( text = data.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(end = 20.dp) + .align(Alignment.CenterVertically) ) - if (data.progress >= 0f) { - LinearProgressIndicator( - progress = { data.progress }, - strokeCap = StrokeCap.Round, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) - ) - } - - if (data.statusRes != NONE) { + data.status?.let { status -> Text( - text = stringResource(id = data.statusRes), + text = status, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(end = 12.dp) + .align(Alignment.CenterVertically) ) } } + if (data.progress >= 0f) { + CircularProgressIndicator( + progress = { data.progress }, + strokeWidth = 3.dp, + strokeCap = StrokeCap.Round, + modifier = Modifier + .size(24.dp, 24.dp) + ) + } + if (data.actionRes != NONE) { Buttons.Small( - onClick = onActionClick, + onClick = onSkipClick, modifier = Modifier.padding(start = 8.dp) ) { Text(text = stringResource(id = data.actionRes)) } } + + if (data.showDismissAction) { + val interactionSource = remember { MutableInteractionSource() } + + Icon( + painter = painterResource(id = R.drawable.symbol_x_24), + contentDescription = stringResource(R.string.Material3SearchToolbar__close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(24.dp) + .clickable( + interactionSource = interactionSource, + indication = ripple(bounded = false), + onClick = onDismissClick + ) + ) + } } } @@ -113,29 +150,37 @@ fun BackupStatus( fun BackupStatusPreview() { Previews.Preview { Column { + BackupStatus( + data = BackupStatusData.RestoringMedia(5755000.bytes, 1253.mebiBytes) + ) + + HorizontalDivider() + + BackupStatus( + data = BackupStatusData.RestoringMedia( + bytesDownloaded = 55000.bytes, + bytesTotal = 1253.mebiBytes, + restoreStatus = BackupStatusData.RestoreStatus.FINISHED + ) + ) + + HorizontalDivider() + + BackupStatus( + data = BackupStatusData.NotEnoughFreeSpace(40900.kibiBytes) + ) + + HorizontalDivider() + BackupStatus( data = BackupStatusData.CouldNotCompleteBackup ) - - HorizontalDivider() - - BackupStatus( - data = BackupStatusData.NotEnoughFreeSpace("12 GB") - ) - - HorizontalDivider() - - BackupStatus( - data = BackupStatusData.RestoringMedia(50, 100) - ) } } } /** * Sealed interface describing status data to display in BackupStatus widget. - * - * TODO [message-requests] - Finalize assets and text */ sealed interface BackupStatusData { @@ -150,16 +195,18 @@ sealed interface BackupStatusData { @get:StringRes val actionRes: Int get() = NONE - @get:StringRes - val statusRes: Int get() = NONE + @get:Composable + val status: String? get() = null val progress: Float get() = NONE.toFloat() + val showDismissAction: Boolean get() = false + /** * Generic failure */ data object CouldNotCompleteBackup : BackupStatusData { - override val iconRes: Int = R.drawable.symbol_backup_light + override val iconRes: Int = R.drawable.symbol_backup_error_24 override val title: String @Composable @@ -172,9 +219,11 @@ sealed interface BackupStatusData { * User does not have enough space on their device to complete backup restoration */ class NotEnoughFreeSpace( - private val requiredSpace: String + requiredSpace: ByteSize ) : BackupStatusData { - override val iconRes: Int = R.drawable.symbol_backup_light + private val requiredSpace = requiredSpace.toUnitString(maxPlaces = 2) + + override val iconRes: Int = R.drawable.symbol_backup_error_24 override val title: String @Composable @@ -188,44 +237,51 @@ sealed interface BackupStatusData { * Restoring media, finished, and paused states. */ data class RestoringMedia( - val bytesDownloaded: Long, - val bytesTotal: Long, - val status: Status = Status.NONE + val bytesDownloaded: ByteSize = 0.bytes, + val bytesTotal: ByteSize = 0.bytes, + val restoreStatus: RestoreStatus = RestoreStatus.NORMAL ) : BackupStatusData { override val iconRes: Int = R.drawable.symbol_backup_light - override val iconColors: BackupsIconColors = BackupsIconColors.Normal + override val iconColors: BackupsIconColors = if (restoreStatus == RestoreStatus.FINISHED) BackupsIconColors.Success else BackupsIconColors.Normal + override val showDismissAction: Boolean = restoreStatus == RestoreStatus.FINISHED override val title: String @Composable get() = stringResource( - when (status) { - Status.NONE -> R.string.default_error_msg - Status.LOW_BATTERY -> R.string.default_error_msg - Status.WAITING_FOR_INTERNET -> R.string.default_error_msg - Status.WAITING_FOR_WIFI -> R.string.default_error_msg - Status.FINISHED -> R.string.default_error_msg + when (restoreStatus) { + RestoreStatus.NORMAL -> R.string.BackupStatus__restoring_media + RestoreStatus.LOW_BATTERY -> R.string.BackupStatus__restore_paused + RestoreStatus.WAITING_FOR_INTERNET -> R.string.BackupStatus__restore_paused + RestoreStatus.WAITING_FOR_WIFI -> R.string.BackupStatus__restore_paused + RestoreStatus.FINISHED -> R.string.BackupStatus__restore_complete } ) - override val statusRes: Int = when (status) { - Status.NONE -> NONE - Status.LOW_BATTERY -> R.string.default_error_msg - Status.WAITING_FOR_INTERNET -> R.string.default_error_msg - Status.WAITING_FOR_WIFI -> R.string.default_error_msg - Status.FINISHED -> R.string.default_error_msg - } + override val status: String + @Composable get() = when (restoreStatus) { + RestoreStatus.NORMAL -> stringResource( + R.string.BackupStatus__status_size_of_size, + bytesDownloaded.toUnitString(maxPlaces = 2), + bytesTotal.toUnitString(maxPlaces = 2) + ) - override val progress: Float = if (bytesTotal > 0) { - min(1f, max(0f, bytesDownloaded.toFloat() / bytesTotal)) + RestoreStatus.LOW_BATTERY -> stringResource(R.string.BackupStatus__status_device_has_low_battery) + RestoreStatus.WAITING_FOR_INTERNET -> stringResource(R.string.BackupStatus__status_no_internet) + RestoreStatus.WAITING_FOR_WIFI -> stringResource(R.string.BackupStatus__status_waiting_for_wifi) + RestoreStatus.FINISHED -> bytesTotal.toUnitString() + } + + override val progress: Float = if (bytesTotal.bytes > 0 && restoreStatus == RestoreStatus.NORMAL) { + min(1f, max(0f, bytesDownloaded.bytes.toFloat() / bytesTotal.bytes.toFloat())) } else { - 0f + NONE.toFloat() } } /** * Describes the status of an in-progress media download session. */ - enum class Status { - NONE, + enum class RestoreStatus { + NORMAL, LOW_BATTERY, WAITING_FOR_INTERNET, WAITING_FOR_WIFI, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt index 890ede5e27..fc872e7bdb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt @@ -323,7 +323,7 @@ private fun getFeatures(messageBackupsType: MessageBackupsType): List { val photoCount = messageBackupsType.storageAllowanceBytes / ByteUnit.MEGABYTES.toBytes(2) val photoCountThousands = photoCount / 1000 - val (count, size) = messageBackupsType.storageAllowanceBytes.bytes.getLargestNonZeroValue() + val sizeUnitString = messageBackupsType.storageAllowanceBytes.bytes.toUnitString(spaced = false) persistentListOf( MessageBackupsTypeFeature( @@ -338,7 +338,7 @@ private fun getFeatures(messageBackupsType: MessageBackupsType): List() { +@OptIn(ExperimentalCoroutinesApi::class) +class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerListener) : Banner() { + + private var totalRestoredSize: Long = 0 override val enabled: Boolean - get() = SignalStore.backup.isRestoreInProgress + get() = SignalStore.backup.isRestoreInProgress || totalRestoredSize > 0 - override val dataFlow: Flow - get() { - if (!SignalStore.backup.isRestoreInProgress) { - return flowOf(BackupStatusData.RestoringMedia(0, 0)) - } + override val dataFlow: Flow by lazy { + SignalStore + .backup + .totalRestorableAttachmentSizeFlow + .flatMapLatest { size -> + when { + size > 0 -> { + totalRestoredSize = size + getActiveRestoreFlow() + } - val dbNotificationFlow = callbackFlow { - val queryObserver = DatabaseObserver.Observer { - trySend(Unit) - } + totalRestoredSize > 0 -> { + flowOf( + BackupStatusData.RestoringMedia( + bytesTotal = totalRestoredSize.bytes.also { totalRestoredSize = 0 }, + restoreStatus = BackupStatusData.RestoreStatus.FINISHED + ) + ) + } - queryObserver.onChanged() - AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(queryObserver) - - awaitClose { - AppDependencies.databaseObserver.unregisterObserver(queryObserver) + else -> flowOf(BackupStatusData.RestoringMedia()) } } - - return dbNotificationFlow - .throttleLatest(1.seconds) - .map { - val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize - val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() - val completedBytes = totalRestoreSize - remainingAttachmentSize - - BackupStatusData.RestoringMedia(completedBytes, totalRestoreSize) - } - .flowOn(Dispatchers.IO) - } + } @Composable override fun DisplayBanner(model: BackupStatusData, contentPadding: PaddingValues) { - BackupStatus(data = model) + BackupStatus( + data = model, + onSkipClick = listener::onSkip, + onDismissClick = listener::onDismissComplete + ) + } + + private fun getActiveRestoreFlow(): Flow { + val flow: Flow = callbackFlow { + val onChange = { trySend(Unit) } + + val observer = DatabaseObserver.Observer { + onChange() + } + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + onChange() + } + } + + onChange() + + AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(observer) + AppDependencies.application.registerReceiver(receiver, IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)) + AppDependencies.application.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + + awaitClose { + AppDependencies.databaseObserver.unregisterObserver(observer) + AppDependencies.application.safeUnregisterReceiver(receiver) + } + } + + return flow + .throttleLatest(1.seconds) + .map { + when { + !WifiConstraint.isMet(AppDependencies.application) -> BackupStatusData.RestoringMedia(restoreStatus = BackupStatusData.RestoreStatus.WAITING_FOR_WIFI) + !NetworkConstraint.isMet(AppDependencies.application) -> BackupStatusData.RestoringMedia(restoreStatus = BackupStatusData.RestoreStatus.WAITING_FOR_INTERNET) + !BatteryNotLowConstraint.isMet() -> BackupStatusData.RestoringMedia(restoreStatus = BackupStatusData.RestoreStatus.LOW_BATTERY) + else -> { + val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize + val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() + val completedBytes = totalRestoreSize - remainingAttachmentSize + + BackupStatusData.RestoringMedia(completedBytes.bytes, totalRestoreSize.bytes) + } + } + } + .flowOn(Dispatchers.IO) + } + + interface RestoreProgressBannerListener { + fun onSkip() + fun onDismissComplete() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt index bd2deba6d2..31b9214410 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -29,7 +30,6 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels import org.signal.core.ui.BottomSheets import org.signal.core.ui.Buttons -import org.signal.core.ui.Icons import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview import org.thoughtcrime.securesms.R @@ -89,10 +89,10 @@ private fun UpgradeToEnableOptimizedStorageSheetContent( ) { BottomSheets.Handle() - Icons.BrushedForeground( + Icon( painter = painterResource(id = R.drawable.symbol_backup_light), contentDescription = null, - foregroundBrush = BackupsIconColors.Normal.foreground, + tint = BackupsIconColors.Normal.foreground, modifier = Modifier .padding(top = 8.dp, bottom = 12.dp) .size(88.dp) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index e43fc2b238..52e47437d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -868,7 +868,17 @@ public class ConversationListFragment extends MainFragment implements ActionMode } return Unit.INSTANCE; }), - new MediaRestoreProgressBanner() + new MediaRestoreProgressBanner(new MediaRestoreProgressBanner.RestoreProgressBannerListener() { + @Override + public void onSkip() { + // TODO [backups] add skip restore ability + } + + @Override + public void onDismissComplete() { + bannerManager.updateContent(bannerView.get()); + } + }) ); this.bannerManager = new BannerManager(bannerRepositories); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/BatteryNotLowConstraint.kt b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/BatteryNotLowConstraint.kt new file mode 100644 index 0000000000..45418aef49 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/BatteryNotLowConstraint.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.jobmanager.impl + +import android.app.job.JobInfo +import androidx.annotation.RequiresApi +import org.thoughtcrime.securesms.jobmanager.Constraint + +/** + * Job constraint for determining whether or not the device battery is not low. + */ +class BatteryNotLowConstraint private constructor() : Constraint { + companion object { + const val KEY: String = "BatteryNotLowConstraint" + + fun isMet(): Boolean { + return ChargingAndBatteryIsNotLowConstraintObserver.isCharging() || ChargingAndBatteryIsNotLowConstraintObserver.isBatteryNotLow() + } + } + + override fun getFactoryKey(): String = KEY + + override fun isMet(): Boolean { + return Companion.isMet() + } + + @RequiresApi(26) + override fun applyToJobInfo(jobInfoBuilder: JobInfo.Builder) { + jobInfoBuilder.setRequiresBatteryNotLow(true) + } + + override fun getJobSchedulerKeyPart(): String? { + return "BATTERY_NOT_LOW" + } + + class Factory : Constraint.Factory { + override fun create(): BatteryNotLowConstraint { + return BatteryNotLowConstraint() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingAndBatteryIsNotLowConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingAndBatteryIsNotLowConstraintObserver.java new file mode 100644 index 0000000000..921d06fbc9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingAndBatteryIsNotLowConstraintObserver.java @@ -0,0 +1,85 @@ +package org.thoughtcrime.securesms.jobmanager.impl; + +import android.app.Application; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; + +/** + * Observes the charging state and low battery state of the device and notifies the JobManager system when appropriate. + */ +public class ChargingAndBatteryIsNotLowConstraintObserver implements ConstraintObserver { + + private static final String REASON = Log.tag(ChargingAndBatteryIsNotLowConstraintObserver.class); + private static final int STATUS_BATTERY = 0; + private static final int LOW_BATTERY_LEVEL = 20; + + private final Application application; + + private static volatile boolean charging; + private static volatile boolean batteryNotLow; + + public ChargingAndBatteryIsNotLowConstraintObserver(@NonNull Application application) { + this.application = application; + } + + @Override + public void register(@NonNull Notifier notifier) { + Intent intent = application.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + boolean wasCharging = charging; + boolean wasBatteryNotLow = batteryNotLow; + + charging = isCharging(intent); + batteryNotLow = isBatteryNotLow(intent); + + if ((charging && !wasCharging) || (batteryNotLow && !wasBatteryNotLow)) { + notifier.onConstraintMet(REASON); + } + } + }, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + + charging = isCharging(intent); + } + + public static boolean isCharging() { + return charging; + } + + public static boolean isBatteryNotLow() { + return batteryNotLow; + } + + private static boolean isCharging(@Nullable Intent intent) { + if (intent == null) { + return false; + } + + int status = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, STATUS_BATTERY); + return status != STATUS_BATTERY; + } + + private static boolean isBatteryNotLow(@Nullable Intent intent) { + if (intent == null) { + return false; + } + + int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + + if (level <= 0 || scale <= 0) { + return false; + } + + return ((int) Math.floor(level * 100 / (double) scale)) > LOW_BATTERY_LEVEL; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraint.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraint.java index e67f71c7c8..206b6cae34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraint.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraint.java @@ -19,7 +19,7 @@ public class ChargingConstraint implements Constraint { @Override public boolean isMet() { - return ChargingConstraintObserver.isCharging(); + return ChargingAndBatteryIsNotLowConstraintObserver.isCharging(); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraintObserver.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraintObserver.java deleted file mode 100644 index 811b575ee0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/ChargingConstraintObserver.java +++ /dev/null @@ -1,62 +0,0 @@ -package org.thoughtcrime.securesms.jobmanager.impl; - -import android.app.Application; -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; -import android.os.BatteryManager; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; - -/** - * Observes the charging state of the device and notifies the JobManager system when appropriate. - */ -public class ChargingConstraintObserver implements ConstraintObserver { - - private static final String REASON = Log.tag(ChargingConstraintObserver.class); - private static final int STATUS_BATTERY = 0; - - private final Application application; - - private static volatile boolean charging; - - public ChargingConstraintObserver(@NonNull Application application) { - this.application = application; - } - - @Override - public void register(@NonNull Notifier notifier) { - Intent intent = application.registerReceiver(new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - boolean wasCharging = charging; - - charging = isCharging(intent); - - if (charging && !wasCharging) { - notifier.onConstraintMet(REASON); - } - } - }, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); - - charging = isCharging(intent); - } - - public static boolean isCharging() { - return charging; - } - - private static boolean isCharging(@Nullable Intent intent) { - if (intent == null) { - return false; - } - - int status = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, STATUS_BATTERY); - return status != STATUS_BATTERY; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/WifiConstraint.kt b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/WifiConstraint.kt index 157c4967f9..09bda207fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/WifiConstraint.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/impl/WifiConstraint.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.jobmanager.impl import android.app.Application import android.app.job.JobInfo +import android.content.Context import org.thoughtcrime.securesms.jobmanager.Constraint import org.thoughtcrime.securesms.util.NetworkUtil @@ -17,10 +18,14 @@ class WifiConstraint(private val application: Application) : Constraint { companion object { const val KEY = "WifiConstraint" + + fun isMet(context: Context): Boolean { + return NetworkUtil.isConnectedWifi(context) + } } override fun isMet(): Boolean { - return NetworkUtil.isConnectedWifi(application) + return isMet(application) } override fun getFactoryKey(): String = KEY diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 719bd3d3ad..ea1acb3979 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -10,11 +10,12 @@ import org.thoughtcrime.securesms.jobmanager.ConstraintObserver; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobMigration; import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint; +import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint; import org.thoughtcrime.securesms.jobmanager.impl.CellServiceConstraintObserver; import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraint; import org.thoughtcrime.securesms.jobmanager.impl.ChangeNumberConstraintObserver; import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraint; -import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraintObserver; +import org.thoughtcrime.securesms.jobmanager.impl.ChargingAndBatteryIsNotLowConstraintObserver; import org.thoughtcrime.securesms.jobmanager.impl.DataRestoreConstraint; import org.thoughtcrime.securesms.jobmanager.impl.DataRestoreConstraintObserver; import org.thoughtcrime.securesms.jobmanager.impl.DecryptionsDrainedConstraint; @@ -364,6 +365,7 @@ public final class JobManagerFactories { public static Map getConstraintFactories(@NonNull Application application) { return new HashMap() {{ put(AutoDownloadEmojiConstraint.KEY, new AutoDownloadEmojiConstraint.Factory(application)); + put(BatteryNotLowConstraint.KEY, new BatteryNotLowConstraint.Factory()); put(ChangeNumberConstraint.KEY, new ChangeNumberConstraint.Factory()); put(ChargingConstraint.KEY, new ChargingConstraint.Factory()); put(DataRestoreConstraint.KEY, new DataRestoreConstraint.Factory()); @@ -379,7 +381,7 @@ public final class JobManagerFactories { public static List getConstraintObservers(@NonNull Application application) { return Arrays.asList(CellServiceConstraintObserver.getInstance(application), - new ChargingConstraintObserver(application), + new ChargingAndBatteryIsNotLowConstraintObserver(application), new NetworkConstraintObserver(application), new SqlCipherMigrationConstraintObserver(), new DecryptionsDrainedConstraintObserver(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt index 13204860d4..6ccf262717 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -19,7 +19,8 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.PartProgressEvent import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.JobLogger.format -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint +import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint import org.thoughtcrime.securesms.jobs.protos.RestoreAttachmentJobData import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.MmsException @@ -107,9 +108,9 @@ class RestoreAttachmentJob private constructor( private constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean, queue: String) : this( Parameters.Builder() .setQueue(queue) - .addConstraint(NetworkConstraint.KEY) + .addConstraint(WifiConstraint.KEY) + .addConstraint(BatteryNotLowConstraint.KEY) .setLifespan(TimeUnit.DAYS.toMillis(30)) - .setMaxAttempts(3) .build(), messageId, attachmentId, @@ -129,7 +130,7 @@ class RestoreAttachmentJob private constructor( } @Throws(Exception::class) - public override fun onRun() { + override fun onRun() { doWork() if (!SignalDatabase.messages.isStory(messageId)) { @@ -170,7 +171,7 @@ class RestoreAttachmentJob private constructor( } else { Log.w(TAG, format(this, "onFailure() messageId: $messageId attachmentId: $attachmentId")) - markFailed(messageId, attachmentId) + markFailed(attachmentId) } } @@ -255,7 +256,7 @@ class RestoreAttachmentJob private constructor( } } catch (e: InvalidAttachmentException) { Log.w(TAG, "Experienced exception while trying to download an attachment.", e) - markFailed(messageId, attachmentId) + markFailed(attachmentId) } catch (e: NonSuccessfulResponseCodeException) { if (SignalStore.backup.backsUpMedia) { if (e.code == 404 && !forceTransitTier && attachment.remoteLocation?.isNotBlank() == true) { @@ -269,30 +270,30 @@ class RestoreAttachmentJob private constructor( } Log.w(TAG, "Experienced exception while trying to download an attachment.", e) - markFailed(messageId, attachmentId) + markFailed(attachmentId) } catch (e: MmsException) { Log.w(TAG, "Experienced exception while trying to download an attachment.", e) - markFailed(messageId, attachmentId) + markFailed(attachmentId) } catch (e: MissingConfigurationException) { Log.w(TAG, "Experienced exception while trying to download an attachment.", e) - markFailed(messageId, attachmentId) + markFailed(attachmentId) } catch (e: InvalidMessageException) { Log.w(TAG, "Experienced an InvalidMessageException while trying to download an attachment.", e) if (e.cause is InvalidMacException) { Log.w(TAG, "Detected an invalid mac. Treating as a permanent failure.") - markPermanentlyFailed(messageId, attachmentId) + markPermanentlyFailed(attachmentId) } else { - markFailed(messageId, attachmentId) + markFailed(attachmentId) } } } - private fun markFailed(messageId: Long, attachmentId: AttachmentId) { - SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId) + private fun markFailed(attachmentId: AttachmentId) { + SignalDatabase.attachments.setRestoreTransferState(attachmentId, AttachmentTable.TRANSFER_PROGRESS_FAILED) } - private fun markPermanentlyFailed(messageId: Long, attachmentId: AttachmentId) { - SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId) + private fun markPermanentlyFailed(attachmentId: AttachmentId) { + SignalDatabase.attachments.setRestoreTransferState(attachmentId, AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) } class Factory : Job.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index 802f02c219..6ac3058917 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.keyvalue import com.fasterxml.jackson.annotation.JsonProperty +import kotlinx.coroutines.flow.Flow import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.backup.RestoreState import org.thoughtcrime.securesms.backup.v2.BackupFrequency @@ -65,7 +66,6 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var nextBackupTime: Long by longValue(KEY_NEXT_BACKUP_TIME, -1) var lastBackupTime: Long by longValue(KEY_LAST_BACKUP_TIME, -1) var lastMediaSyncTime: Long by longValue(KEY_LAST_BACKUP_MEDIA_SYNC_TIME, -1) - var totalRestorableAttachmentSize: Long by longValue(KEY_TOTAL_RESTORABLE_ATTACHMENT_SIZE, 0) var backupFrequency: BackupFrequency by enumValue(KEY_BACKUP_FREQUENCY, BackupFrequency.MANUAL, BackupFrequency.Serializer) var backupTier: MessageBackupTier? by enumValue(KEY_BACKUP_TIER, null, MessageBackupTier.Serializer) @@ -96,6 +96,11 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var backupsInitialized: Boolean by booleanValue(KEY_BACKUPS_INITIALIZED, false) + private val totalRestorableAttachmentSizeValue = longValue(KEY_TOTAL_RESTORABLE_ATTACHMENT_SIZE, 0) + var totalRestorableAttachmentSize: Long by totalRestorableAttachmentSizeValue + val totalRestorableAttachmentSizeFlow: Flow + get() = totalRestorableAttachmentSizeValue.toFlow() + val isRestoreInProgress: Boolean get() = totalRestorableAttachmentSize > 0 diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt index cf4c68af57..2b2d680e57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStoreValueDelegates.kt @@ -1,6 +1,8 @@ package org.thoughtcrime.securesms.keyvalue import com.squareup.wire.ProtoAdapter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import org.signal.core.util.LongSerializer import kotlin.reflect.KProperty @@ -45,12 +47,22 @@ internal fun SignalStoreValues.protoValue(key: String, adapter: ProtoAdapter * class to callers and protect the individual implementations as private behind the various extension functions. */ sealed class SignalStoreValueDelegate(private val store: KeyValueStore) { + + private var flow: Lazy> = lazy { MutableStateFlow(getValue(store)) } + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { return getValue(store) } operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { setValue(store, value) + if (flow.isInitialized()) { + flow.value.tryEmit(value) + } + } + + fun toFlow(): Flow { + return flow.value } internal abstract fun getValue(values: KeyValueStore): T diff --git a/app/src/main/res/drawable/symbol_backup_error_24.xml b/app/src/main/res/drawable/symbol_backup_error_24.xml new file mode 100644 index 0000000000..de289d084e --- /dev/null +++ b/app/src/main/res/drawable/symbol_backup_error_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 253ed4a59f..87762db825 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7288,7 +7288,23 @@ - Free up %1$s of space to download your media. + Free up %1$s of space to restore your media. + + Restoring media + + Restore paused + + Restore complete + + + Waiting for Wi-Fi… + + No internet… + + Device has low battery + + %1$s of %2$s + diff --git a/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt b/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt index ad7ec621c3..6d372cd126 100644 --- a/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt +++ b/core-util-jvm/src/main/java/org/signal/core/util/ByteExtensions.kt @@ -5,6 +5,9 @@ package org.signal.core.util +import java.text.NumberFormat +import kotlin.math.min + inline val Long.bytes: ByteSize get() = ByteSize(this) @@ -15,41 +18,41 @@ inline val Long.kibiBytes: ByteSize get() = (this * 1024).bytes inline val Int.kibiBytes: ByteSize - get() = (this * 1024).bytes + get() = (this.toLong() * 1024L).bytes inline val Long.mebiBytes: ByteSize - get() = (this * 1024).kibiBytes + get() = (this * 1024L).kibiBytes inline val Int.mebiBytes: ByteSize - get() = (this * 1024).kibiBytes + get() = (this.toLong() * 1024L).kibiBytes inline val Long.gibiBytes: ByteSize - get() = (this * 1024).mebiBytes + get() = (this * 1024L).mebiBytes inline val Int.gibiBytes: ByteSize - get() = (this * 1024).mebiBytes + get() = (this.toLong() * 1024L).mebiBytes inline val Long.tebiBytes: ByteSize - get() = (this * 1024).gibiBytes + get() = (this * 1024L).gibiBytes inline val Int.tebiBytes: ByteSize - get() = (this * 1024).gibiBytes + get() = (this.toLong() * 1024L).gibiBytes class ByteSize(val bytes: Long) { val inWholeBytes: Long get() = bytes val inWholeKibiBytes: Long - get() = bytes / 1024 + get() = bytes / 1024L val inWholeMebiBytes: Long - get() = inWholeKibiBytes / 1024 + get() = inWholeKibiBytes / 1024L val inWholeGibiBytes: Long - get() = inWholeMebiBytes / 1024 + get() = inWholeMebiBytes / 1024L val inWholeTebiBytes: Long - get() = inWholeGibiBytes / 1024 + get() = inWholeGibiBytes / 1024L val inKibiBytes: Float get() = bytes / 1024f @@ -63,16 +66,35 @@ class ByteSize(val bytes: Long) { val inTebiBytes: Float get() = inGibiBytes / 1024f - fun getLargestNonZeroValue(): Pair { + fun getLargestNonZeroValue(): Pair { return when { - inWholeTebiBytes > 0L -> inWholeTebiBytes to Size.TEBIBYTE - inWholeGibiBytes > 0L -> inWholeGibiBytes to Size.GIBIBYTE - inWholeMebiBytes > 0L -> inWholeMebiBytes to Size.MEBIBYTE - inWholeKibiBytes > 0L -> inWholeKibiBytes to Size.KIBIBYTE - else -> inWholeBytes to Size.BYTE + inWholeTebiBytes > 0L -> inTebiBytes to Size.TEBIBYTE + inWholeGibiBytes > 0L -> inGibiBytes to Size.GIBIBYTE + inWholeMebiBytes > 0L -> inMebiBytes to Size.MEBIBYTE + inWholeKibiBytes > 0L -> inKibiBytes to Size.KIBIBYTE + else -> inWholeBytes.toFloat() to Size.BYTE } } + fun toUnitString(maxPlaces: Int = 1, spaced: Boolean = true): String { + val (size, unit) = getLargestNonZeroValue() + + val formatter = NumberFormat.getInstance().apply { + minimumFractionDigits = 0 + maximumFractionDigits = when (unit) { + Size.BYTE, + Size.KIBIBYTE -> 0 + + Size.MEBIBYTE -> min(1, maxPlaces) + + Size.GIBIBYTE, + Size.TEBIBYTE -> min(2, maxPlaces) + } + } + + return "${formatter.format(size)}${if (spaced) " " else ""}${unit.label}" + } + enum class Size(val label: String) { BYTE("B"), KIBIBYTE("KB"),