Update restore progress banner UI/UX and job behavior.

This commit is contained in:
Cody Henthorne
2024-10-02 09:49:28 -04:00
committed by Greyson Parrelli
parent 320d51707d
commit 93609106b0
20 changed files with 491 additions and 229 deletions

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -323,7 +323,7 @@ private fun getFeatures(messageBackupsType: MessageBackupsType): List<MessageBac
is MessageBackupsType.Paid -> {
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<MessageBac
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = stringResource(
id = R.string.MessageBackupsTypeSelectionScreen__s_of_storage_s_photos,
"${count}${size.label}",
sizeUnitString,
"~${photoCountThousands}K"
)
),

View File

@@ -5,15 +5,23 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.signal.core.util.bytes
import org.signal.core.util.throttleLatest
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatus
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
@@ -21,47 +29,102 @@ import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import kotlin.time.Duration.Companion.seconds
class MediaRestoreProgressBanner : Banner<BackupStatusData>() {
@OptIn(ExperimentalCoroutinesApi::class)
class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerListener) : Banner<BackupStatusData>() {
private var totalRestoredSize: Long = 0
override val enabled: Boolean
get() = SignalStore.backup.isRestoreInProgress
get() = SignalStore.backup.isRestoreInProgress || totalRestoredSize > 0
override val dataFlow: Flow<BackupStatusData>
get() {
if (!SignalStore.backup.isRestoreInProgress) {
return flowOf(BackupStatusData.RestoringMedia(0, 0))
}
override val dataFlow: Flow<BackupStatusData> 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<BackupStatusData.RestoringMedia> {
val flow: Flow<Unit> = 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()
}
}

View File

@@ -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)

View File

@@ -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);

View File

@@ -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<BatteryNotLowConstraint?> {
override fun create(): BatteryNotLowConstraint {
return BatteryNotLowConstraint()
}
}
}

View File

@@ -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;
}
}

View File

@@ -19,7 +19,7 @@ public class ChargingConstraint implements Constraint {
@Override
public boolean isMet() {
return ChargingConstraintObserver.isCharging();
return ChargingAndBatteryIsNotLowConstraintObserver.isCharging();
}
@Override

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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<String, Constraint.Factory> getConstraintFactories(@NonNull Application application) {
return new HashMap<String, Constraint.Factory>() {{
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<ConstraintObserver> getConstraintObservers(@NonNull Application application) {
return Arrays.asList(CellServiceConstraintObserver.getInstance(application),
new ChargingConstraintObserver(application),
new ChargingAndBatteryIsNotLowConstraintObserver(application),
new NetworkConstraintObserver(application),
new SqlCipherMigrationConstraintObserver(),
new DecryptionsDrainedConstraintObserver(),

View File

@@ -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<RestoreAttachmentJob?> {

View File

@@ -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<Long>
get() = totalRestorableAttachmentSizeValue.toFlow()
val isRestoreInProgress: Boolean
get() = totalRestorableAttachmentSize > 0

View File

@@ -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 <M> SignalStoreValues.protoValue(key: String, adapter: ProtoAdapter
* class to callers and protect the individual implementations as private behind the various extension functions.
*/
sealed class SignalStoreValueDelegate<T>(private val store: KeyValueStore) {
private var flow: Lazy<MutableStateFlow<T>> = 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<T> {
return flow.value
}
internal abstract fun getValue(values: KeyValueStore): T

View File

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12 2.88c-5.04 0-9.13 4.08-9.13 9.12 0 2.34 0.88 4.47 2.33 6.09l0.48-0.48c0.41-0.4 1.11-0.21 1.26 0.35l0.76 2.96c0.14 0.56-0.37 1.06-0.92 0.92l-2.96-0.76c-0.57-0.15-0.76-0.85-0.35-1.26l0.5-0.5C2.2 17.4 1.11 14.83 1.11 12 1.13 6 6 1.12 12 1.12 18 1.13 22.88 6 22.88 12c0 6-4.87 10.88-10.88 10.88-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88c5.04 0 9.13-4.08 9.13-9.12 0-5.04-4.09-9.13-9.13-9.13Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12 6.38c-0.7 0-1.24 0.59-1.2 1.28l0.43 5.5c0.03 0.4 0.37 0.71 0.77 0.71s0.74-0.3 0.77-0.7l0.42-5.5c0.05-0.7-0.5-1.3-1.19-1.3Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M10.75 16.38c0-0.7 0.56-1.25 1.25-1.25s1.25 0.55 1.25 1.24c0 0.7-0.56 1.25-1.25 1.25s-1.25-0.55-1.25-1.25Z"/>
</vector>

View File

@@ -7288,7 +7288,23 @@
<!-- BackupStatus -->
<!-- Status title when user does not have enough free space to download their media. Placeholder is required disk space. -->
<string name="BackupStatus__free_up_s_of_space_to_download_your_media">Free up %1$s of space to download your media.</string>
<string name="BackupStatus__free_up_s_of_space_to_download_your_media">Free up %1$s of space to restore your media.</string>
<!-- Status title for banner when user is actively restoring media from a backup -->
<string name="BackupStatus__restoring_media">Restoring media</string>
<!-- Status title for banner when user has paused restore media from a backup -->
<string name="BackupStatus__restore_paused">Restore paused</string>
<!-- Status title for banner when user has completed restoring restore media from a backup -->
<string name="BackupStatus__restore_complete">Restore complete</string>
<!-- Status subtitle for banner when restoring media pauses for Wi-Fi -->
<string name="BackupStatus__status_waiting_for_wifi">Waiting for Wi-Fi…</string>
<!-- Status subtitle for banner when restoring media pauses for internet in general -->
<string name="BackupStatus__status_no_internet">No internet…</string>
<!-- Status subtitle for banner when restoring media pauses for low battery -->
<string name="BackupStatus__status_device_has_low_battery">Device has low battery</string>
<!-- Status subtitle for banner when restoring media. Placeholders are size already restored and total size to restore. e.g., 4.5MB of 100MB -->
<string name="BackupStatus__status_size_of_size">%1$s of %2$s</string>
<!-- BackupsTypeSettingsFragment -->
<!-- Displayed as the user\'s payment method as a label in a preference row -->

View File

@@ -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<Long, Size> {
fun getLargestNonZeroValue(): Pair<Float, Size> {
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"),