Add restore local backupv2 infra.

This commit is contained in:
Cody Henthorne 2024-09-03 16:49:33 -04:00
parent 00d20a1917
commit a8bf03af89
41 changed files with 615 additions and 324 deletions

View file

@ -365,8 +365,7 @@ object BackupRepository {
private fun import(
backupKey: BackupKey,
frameReader: BackupImportReader,
selfData: SelfData,
importExtras: ((EventTimer) -> Unit)? = null
selfData: SelfData
): ImportResult {
val eventTimer = EventTimer()
@ -444,23 +443,27 @@ object BackupRepository {
eventTimer.emit("chatItem")
}
importExtras?.invoke(eventTimer)
importState.chatIdToLocalThreadId.values.forEach {
SignalDatabase.threads.update(it, unarchive = false, allowDeletion = false)
}
}
SignalDatabase.groups.getGroups().use { groups ->
while (groups.hasNext()) {
val group = groups.next()
if (group.id.isV2) {
AppDependencies.jobManager.add(RequestGroupV2InfoJob(group.id as GroupId.V2))
}
}
}
Log.d(TAG, "import() ${eventTimer.stop().summary}")
val groupJobs = SignalDatabase.groups.getGroups().use { groups ->
groups
.asSequence()
.mapNotNull { group ->
if (group.id.isV2) {
RequestGroupV2InfoJob(group.id as GroupId.V2)
} else {
null
}
}
.toList()
}
AppDependencies.jobManager.addAll(groupJobs)
return ImportResult.Success(backupTime = header.backupTimeMs)
}

View file

@ -63,6 +63,10 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
fun fromFile(context: Context, backupDirectory: File): ArchiveFileSystem {
return ArchiveFileSystem(context, DocumentFile.fromFile(backupDirectory))
}
fun openInputStream(context: Context, uri: Uri): InputStream? {
return context.contentResolver.openInputStream(uri)
}
}
private val signalBackups: DocumentFile
@ -284,29 +288,22 @@ class FilesFileSystem(private val context: Context, private val root: DocumentFi
* undefined and should be avoided.
*/
fun fileOutputStream(mediaName: MediaName): OutputStream? {
val subFileDirectoryName = mediaName.name.substring(0..1)
val subFileDirectory = subFolders[subFileDirectoryName]!!
val subFileDirectory = subFileDirectoryFor(mediaName)
val file = subFileDirectory.createFile("application/octet-stream", mediaName.name)
return file?.outputStream(context)
}
/**
* Given a [file], open and return an [InputStream].
*/
fun fileInputStream(file: DocumentFileInfo): InputStream? {
return file.documentFile.inputStream(context)
}
/**
* Delete a file for the given [mediaName] if it exists.
*
* @return true if deleted, false if not, null if not found
*/
fun delete(mediaName: MediaName): Boolean? {
val subFileDirectoryName = mediaName.name.substring(0..1)
val subFileDirectory = subFolders[subFileDirectoryName]!!
return subFileDirectoryFor(mediaName).delete(context, mediaName.name)
}
return subFileDirectory.delete(context, mediaName.name)
private fun subFileDirectoryFor(mediaName: MediaName): DocumentFile {
return subFolders[mediaName.name.substring(0..1)]!!
}
}

View file

@ -9,13 +9,14 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -45,11 +46,13 @@ private const val NONE = -1
@Composable
fun BackupStatus(
data: BackupStatusData,
onActionClick: () -> Unit = {}
onActionClick: () -> Unit = {},
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(contentPadding)
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
.fillMaxWidth()
.padding(14.dp)
@ -71,7 +74,8 @@ fun BackupStatus(
) {
Text(
text = data.title,
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (data.progress >= 0f) {
@ -108,17 +112,19 @@ fun BackupStatus(
@Composable
fun BackupStatusPreview() {
Previews.Preview {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Column {
BackupStatus(
data = BackupStatusData.CouldNotCompleteBackup
)
HorizontalDivider()
BackupStatus(
data = BackupStatusData.NotEnoughFreeSpace("12 GB")
)
HorizontalDivider()
BackupStatus(
data = BackupStatusData.RestoringMedia(50, 100)
)
@ -201,7 +207,7 @@ sealed interface BackupStatusData {
)
override val statusRes: Int = when (status) {
Status.NONE -> R.string.default_error_msg
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

View file

@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ -44,5 +45,5 @@ abstract class Banner {
* @see [org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner]
*/
@Composable
abstract fun DisplayBanner()
abstract fun DisplayBanner(contentPadding: PaddingValues)
}

View file

@ -6,11 +6,14 @@
package org.thoughtcrime.securesms.banner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
/**
@ -47,8 +50,10 @@ class BannerManager @JvmOverloads constructor(
val bannerToDisplay = state.value.firstOrNull()
if (bannerToDisplay != null) {
Box {
bannerToDisplay.DisplayBanner()
SignalTheme {
Box {
bannerToDisplay.DisplayBanner(PaddingValues(horizontal = 12.dp, vertical = 8.dp))
}
}
onNewBannerShownListener()

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.banner.banners
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
@ -21,7 +22,7 @@ class BubbleOptOutBanner(inBubble: Boolean, private val actionListener: (Boolean
override val enabled: Boolean = inBubble && !SignalStore.tooltips.hasSeenBubbleOptOutTooltip() && Build.VERSION.SDK_INT > 29
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.BubbleOptOutTooltip__description),
@ -32,7 +33,8 @@ class BubbleOptOutBanner(inBubble: Boolean, private val actionListener: (Boolean
Action(R.string.BubbleOptOutTooltip__not_now) {
actionListener(false)
}
)
),
paddingValues = contentPadding
)
}

View file

@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentManager
@ -24,7 +25,7 @@ class CdsPermanentErrorBanner(private val fragmentManager: FragmentManager) : Ba
override val enabled: Boolean = SignalStore.misc.isCdsBlocked && timeUntilUnblock >= PERMANENT_TIME_CUTOFF
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_cds_permanent_error_body),
@ -33,7 +34,8 @@ class CdsPermanentErrorBanner(private val fragmentManager: FragmentManager) : Ba
Action(R.string.reminder_cds_permanent_error_learn_more) {
CdsPermanentErrorBottomSheet.show(fragmentManager)
}
)
),
paddingValues = contentPadding
)
}

View file

@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.FragmentManager
@ -23,7 +24,7 @@ class CdsTemporaryErrorBanner(private val fragmentManager: FragmentManager) : Ba
override val enabled: Boolean = SignalStore.misc.isCdsBlocked && timeUntilUnblock < CdsPermanentErrorBanner.PERMANENT_TIME_CUTOFF
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_cds_warning_body),
@ -32,7 +33,8 @@ class CdsTemporaryErrorBanner(private val fragmentManager: FragmentManager) : Ba
Action(R.string.reminder_cds_warning_learn_more) {
CdsTemporaryErrorBottomSheet.show(fragmentManager)
}
)
),
paddingValues = contentPadding
)
}

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import android.os.Build
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
@ -25,23 +26,24 @@ class DozeBanner(private val context: Context, val dismissed: Boolean, private v
Build.VERSION.SDK_INT >= 23 && !SignalStore.account.fcmEnabled && !TextSecurePreferences.hasPromptedOptimizeDoze(context) && !ServiceUtil.getPowerManager(context).isIgnoringBatteryOptimizations(context.packageName)
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
if (Build.VERSION.SDK_INT < 23) {
throw IllegalStateException("Showing a Doze banner for an OS prior to Android 6.0")
}
DefaultBanner(
title = stringResource(id = R.string.DozeReminder_optimize_for_missing_play_services),
body = stringResource(id = R.string.DozeReminder_this_device_does_not_support_play_services_tap_to_disable_system_battery),
onDismissListener = {
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
onDismiss()
},
actions = listOf(
Action(android.R.string.ok) {
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
PowerManagerCompat.requestIgnoreBatteryOptimizations(context)
}
),
onDismissListener = {
TextSecurePreferences.setPromptedOptimizeDoze(context, true)
onDismiss()
}
paddingValues = contentPadding
)
}

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
@ -21,7 +22,7 @@ class EnclaveFailureBanner(enclaveFailed: Boolean, private val context: Context)
override val enabled: Boolean = enclaveFailed
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.EnclaveFailureReminder_update_signal),
@ -30,7 +31,8 @@ class EnclaveFailureBanner(enclaveFailed: Boolean, private val context: Context)
Action(R.string.ExpiredBuildReminder_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
)
),
paddingValues = contentPadding
)
}

View file

@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import kotlinx.coroutines.flow.Flow
@ -18,7 +19,7 @@ class GroupsV1MigrationSuggestionsBanner(private val suggestionsSize: Int, priva
override val enabled: Boolean = suggestionsSize > 0
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = pluralStringResource(
@ -29,7 +30,8 @@ class GroupsV1MigrationSuggestionsBanner(private val suggestionsSize: Int, priva
actions = listOf(
Action(R.plurals.GroupsV1MigrationSuggestionsReminder_add_members, isPluralizedLabel = true, pluralQuantity = suggestionsSize, onAddMembers),
Action(R.string.GroupsV1MigrationSuggestionsReminder_no_thanks, onClick = onNoThanks)
)
),
paddingValues = contentPadding
)
}

View file

@ -5,18 +5,22 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.flowWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatus
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.banner.Banner
@ -24,68 +28,71 @@ import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration.Companion.seconds
class MediaRestoreProgressBanner(private val data: MediaRestoreEvent) : Banner() {
companion object {
private val TAG = Log.tag(MediaRestoreProgressBanner::class)
/**
* Create a Lifecycle-aware [Flow] of [MediaRestoreProgressBanner] that observes the database for changes in attachments and emits banners when attachments are updated.
*/
@JvmStatic
fun createLifecycleAwareFlow(lifecycleOwner: LifecycleOwner): Flow<MediaRestoreProgressBanner> {
if (SignalStore.backup.isRestoreInProgress) {
val observer = LifecycleObserver()
lifecycleOwner.lifecycle.addObserver(observer)
return observer.flow
return if (SignalStore.backup.isRestoreInProgress) {
restoreFlow(lifecycleOwner)
} else {
return flow {
flow {
emit(MediaRestoreProgressBanner(MediaRestoreEvent(0L, 0L)))
}
}
}
/**
* Create a flow that listens for all attachment changes in the db and emits a new banner at most
* once every 1 second.
*/
private fun restoreFlow(lifecycleOwner: LifecycleOwner): Flow<MediaRestoreProgressBanner> {
val flow = callbackFlow {
val queryObserver = DatabaseObserver.Observer {
trySend(Unit)
}
queryObserver.onChanged()
AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(queryObserver)
awaitClose {
AppDependencies.databaseObserver.unregisterObserver(queryObserver)
}
}
return flow
.flowWithLifecycle(lifecycleOwner.lifecycle)
.buffer(1, BufferOverflow.DROP_OLDEST)
.onEach { delay(1.seconds) }
.map { MediaRestoreProgressBanner(loadData()) }
.flowOn(Dispatchers.IO)
}
private suspend fun loadData() = withContext(Dispatchers.IO) {
// TODO [backups]: define and query data for interrupted/paused restores
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
val remainingAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
val completedBytes = totalRestoreSize - remainingAttachmentSize
if (remainingAttachmentSize == 0L) {
SignalStore.backup.totalRestorableAttachmentSize = 0
}
MediaRestoreEvent(completedBytes, totalRestoreSize)
}
}
override var enabled: Boolean = data.totalBytes > 0L && data.totalBytes != data.completedBytes
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
BackupStatus(data = BackupStatusData.RestoringMedia(data.completedBytes, data.totalBytes))
}
data class MediaRestoreEvent(val completedBytes: Long, val totalBytes: Long)
private class LifecycleObserver : DefaultLifecycleObserver {
private var attachmentObserver: DatabaseObserver.Observer? = null
private val _mutableSharedFlow = MutableSharedFlow<MediaRestoreEvent>(replay = 1)
val flow = _mutableSharedFlow.map { MediaRestoreProgressBanner(it) }
override fun onStart(owner: LifecycleOwner) {
val queryObserver = DatabaseObserver.Observer {
owner.lifecycleScope.launch {
_mutableSharedFlow.emit(loadData())
}
}
attachmentObserver = queryObserver
queryObserver.onChanged()
AppDependencies.databaseObserver.registerAttachmentObserver(queryObserver)
}
override fun onStop(owner: LifecycleOwner) {
attachmentObserver?.let {
AppDependencies.databaseObserver.unregisterObserver(it)
}
}
private suspend fun loadData() = withContext(Dispatchers.IO) {
// TODO [backups]: define and query data for interrupted/paused restores
val totalRestoreSize = SignalStore.backup.totalRestorableAttachmentSize
val remainingAttachmentSize = SignalDatabase.attachments.getTotalRestorableAttachmentSize()
val completedBytes = totalRestoreSize - remainingAttachmentSize
MediaRestoreEvent(completedBytes, totalRestoreSize)
}
}
}

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@ -34,7 +35,7 @@ class OutdatedBuildBanner(val context: Context, private val daysUntilExpiry: Int
}
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
val bodyText = when (status) {
ExpiryStatus.OUTDATED_ONLY -> if (daysUntilExpiry == 0) {
stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today)
@ -63,7 +64,8 @@ class OutdatedBuildBanner(val context: Context, private val daysUntilExpiry: Int
Action(R.string.ExpiredBuildReminder_update_now) {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context)
}
)
),
paddingValues = contentPadding
)
}

View file

@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.banner.banners
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import kotlinx.coroutines.flow.Flow
@ -17,7 +18,7 @@ import org.thoughtcrime.securesms.banner.ui.compose.DefaultBanner
class PendingGroupJoinRequestsBanner(override val enabled: Boolean, private val suggestionsSize: Int, private val onViewClicked: () -> Unit, private val onDismissListener: (() -> Unit)?) : Banner() {
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = pluralStringResource(
@ -25,10 +26,11 @@ class PendingGroupJoinRequestsBanner(override val enabled: Boolean, private val
count = suggestionsSize,
suggestionsSize
),
onDismissListener = onDismissListener,
actions = listOf(
Action(R.string.PendingGroupJoinRequestsReminder_view, onClick = onViewClicked)
),
onDismissListener = onDismissListener
paddingValues = contentPadding
)
}

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
@ -25,11 +26,12 @@ class ServiceOutageBanner(outageInProgress: Boolean) : Banner() {
override val enabled = outageInProgress
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.reminder_header_service_outage_text),
importance = Importance.ERROR
importance = Importance.ERROR,
paddingValues = contentPadding
)
}

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
@ -29,7 +30,7 @@ class UnauthorizedBanner(val context: Context) : Banner() {
override val enabled = TextSecurePreferences.isUnauthorizedReceived(context) || !SignalStore.account.isRegistered
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = stringResource(id = R.string.UnauthorizedReminder_this_is_likely_because_you_registered_your_phone_number_with_Signal_on_a_different_device),
@ -39,7 +40,8 @@ class UnauthorizedBanner(val context: Context) : Banner() {
val registrationIntent = RegistrationActivity.newIntentForReRegistration(context)
context.startActivity(registrationIntent)
}
)
),
paddingValues = contentPadding
)
}

View file

@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.banner.banners
import android.content.Context
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import kotlinx.coroutines.flow.Flow
@ -27,7 +28,7 @@ class UsernameOutOfSyncBanner(private val context: Context, private val username
}
@Composable
override fun DisplayBanner() {
override fun DisplayBanner(contentPadding: PaddingValues) {
DefaultBanner(
title = null,
body = if (usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
@ -40,7 +41,8 @@ class UsernameOutOfSyncBanner(private val context: Context, private val username
Action(R.string.UsernameOutOfSyncReminder__fix_now) {
onActionClick(usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED)
}
)
),
paddingValues = contentPadding
)
}

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -32,7 +33,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.isNotNullOrBlank
import org.thoughtcrime.securesms.R
@ -50,115 +50,116 @@ fun DefaultBanner(
actions: List<Action> = emptyList(),
showProgress: Boolean = false,
progressText: String = "",
progressPercent: Int = -1
progressPercent: Int = -1,
paddingValues: PaddingValues
) {
SignalTheme {
Box(
Box(
modifier = Modifier
.padding(paddingValues)
.background(
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.surface
Importance.ERROR -> colorResource(id = R.color.reminder_background)
}
)
.border(
width = 1.dp,
color = colorResource(id = R.color.signal_colorOutline_38),
shape = RoundedCornerShape(12.dp)
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp)
.background(
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.surface
Importance.ERROR -> colorResource(id = R.color.reminder_background)
}
)
.border(
width = 1.dp,
color = colorResource(id = R.color.signal_colorOutline_38),
shape = RoundedCornerShape(12.dp)
)
.defaultMinSize(minHeight = 74.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.defaultMinSize(minHeight = 74.dp)
) {
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp, top = 16.dp)
) {
if (title.isNotNullOrBlank()) {
Text(
text = title,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurface
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
},
style = MaterialTheme.typography.bodyLarge
Column {
Row(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp, top = 16.dp)
) {
if (title.isNotNullOrBlank()) {
Text(
text = title,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurface
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
},
style = MaterialTheme.typography.bodyLarge
)
}
Text(
text = body,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
},
style = MaterialTheme.typography.bodyMedium
)
if (showProgress) {
if (progressPercent >= 0) {
LinearProgressIndicator(
progress = { progressPercent / 100f },
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier
.padding(vertical = 12.dp)
.fillMaxWidth()
)
} else {
LinearProgressIndicator(
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(vertical = 12.dp)
)
}
Text(
text = body,
text = progressText,
style = MaterialTheme.typography.bodySmall,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
},
style = MaterialTheme.typography.bodyMedium
}
)
if (showProgress) {
if (progressPercent >= 0) {
LinearProgressIndicator(
progress = { progressPercent / 100f },
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(vertical = 12.dp)
)
} else {
LinearProgressIndicator(
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(vertical = 12.dp)
)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodySmall,
color = when (importance) {
Importance.NORMAL -> MaterialTheme.colorScheme.onSurfaceVariant
Importance.ERROR -> colorResource(id = R.color.signal_light_colorOnSurface)
}
)
}
}
}
Box(modifier = Modifier.size(48.dp)) {
if (onDismissListener != null) {
IconButton(
onClick = {
onHideListener?.invoke()
onDismissListener()
},
modifier = Modifier.size(48.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_x_24),
contentDescription = stringResource(id = R.string.InviteActivity_cancel)
)
}
Box(modifier = Modifier.size(48.dp)) {
if (onDismissListener != null) {
IconButton(
onClick = {
onHideListener?.invoke()
onDismissListener()
},
modifier = Modifier.size(48.dp)
) {
Icon(
painter = painterResource(id = R.drawable.symbol_x_24),
contentDescription = stringResource(id = R.string.InviteActivity_cancel)
)
}
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(end = 8.dp)
) {
for (action in actions) {
TextButton(onClick = action.onClick) {
Text(
text = if (!action.isPluralizedLabel) {
stringResource(id = action.label)
} else {
pluralStringResource(id = action.label, count = action.pluralQuantity)
}
)
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(end = 8.dp)
) {
for (action in actions) {
TextButton(onClick = action.onClick) {
Text(
text = if (!action.isPluralizedLabel) {
stringResource(id = action.label)
} else {
pluralStringResource(id = action.label, count = action.pluralQuantity)
}
)
}
}
}
@ -183,7 +184,8 @@ private fun BubblesOptOutPreview() {
actions = listOf(
Action(R.string.BubbleOptOutTooltip__turn_off) {},
Action(R.string.BubbleOptOutTooltip__not_now) {}
)
),
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
)
}
}
@ -196,9 +198,10 @@ private fun ForcedUpgradePreview() {
title = null,
body = stringResource(id = R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today),
importance = Importance.ERROR,
actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}),
onDismissListener = {},
onHideListener = { },
onDismissListener = {}
actions = listOf(Action(R.string.ExpiredBuildReminder_update_now) {}),
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
)
}
}
@ -215,11 +218,12 @@ private fun FullyLoadedErrorPreview() {
title = "Error",
body = "Creating more errors.",
importance = Importance.ERROR,
onDismissListener = {},
actions = actions,
showProgress = true,
progressText = "4 out of 10 errors created.",
progressPercent = 40,
onDismissListener = {}
paddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
)
}
}

View file

@ -74,6 +74,7 @@ import org.thoughtcrime.securesms.components.settings.app.internal.backup.Intern
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupUploadState
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
class InternalBackupPlaygroundFragment : ComposeFragment() {
@ -81,7 +82,6 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
private val viewModel: InternalBackupPlaygroundViewModel by viewModels()
private lateinit var exportFileLauncher: ActivityResultLauncher<Intent>
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
private lateinit var importDirectoryLauncher: ActivityResultLauncher<Intent>
private lateinit var validateFileLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
@ -108,12 +108,6 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
}
}
importDirectoryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
viewModel.import(result.data!!.data!!)
}
}
validateFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
@ -141,6 +135,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
Screen(
state = state,
onExportClicked = { viewModel.export() },
onExportDirectoryClicked = { LocalBackupJob.enqueueArchive() },
onImportMemoryClicked = { viewModel.import() },
onImportFileClicked = {
val intent = Intent().apply {
@ -152,8 +147,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
importFileLauncher.launch(intent)
},
onImportDirectoryClicked = {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
importDirectoryLauncher.launch(intent)
viewModel.import(SignalStore.settings.signalBackupDirectory!!)
},
onPlaintextClicked = { viewModel.onPlaintextToggled() },
onSaveToDiskClicked = {
@ -260,6 +254,7 @@ fun Tabs(
fun Screen(
state: ScreenState,
onExportClicked: () -> Unit = {},
onExportDirectoryClicked: () -> Unit = {},
onImportMemoryClicked: () -> Unit = {},
onImportFileClicked: () -> Unit = {},
onImportDirectoryClicked: () -> Unit = {},
@ -302,6 +297,13 @@ fun Screen(
Text("Export")
}
Buttons.LargePrimary(
onClick = onExportDirectoryClicked,
enabled = !state.backupState.inProgress && state.canReadWriteBackupDirectory
) {
Text("Export to backup directory")
}
Buttons.LargePrimary(
onClick = onTriggerBackupJobClicked,
enabled = !state.backupState.inProgress
@ -323,9 +325,10 @@ fun Screen(
Text("Import from file")
}
Buttons.LargeTonal(
onClick = onImportDirectoryClicked
onClick = onImportDirectoryClicked,
enabled = state.canReadWriteBackupDirectory
) {
Text("Import from directory")
Text("Import from backup directory")
}
Buttons.LargeTonal(

View file

@ -9,6 +9,7 @@ import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
@ -53,7 +55,17 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val disposables = CompositeDisposable()
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE, uploadState = BackupUploadState.NONE, plaintext = false))
private val _state: MutableState<ScreenState> = mutableStateOf(
ScreenState(
backupState = BackupState.NONE,
uploadState = BackupUploadState.NONE,
plaintext = false,
canReadWriteBackupDirectory = SignalStore.settings.signalBackupDirectory?.let {
val file = DocumentFile.fromTreeUri(AppDependencies.application, it)
file != null && file.canWrite() && file.canRead()
} ?: false
)
)
val state: State<ScreenState> = _state
private val _mediaState: MutableState<MediaState> = mutableStateOf(MediaState())
@ -129,6 +141,9 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
LocalArchiver.import(snapshotFileSystem, selfData)
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
@ -382,7 +397,8 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val backupState: BackupState = BackupState.NONE,
val uploadState: BackupUploadState = BackupUploadState.NONE,
val remoteBackupState: RemoteBackupState = RemoteBackupState.Unknown,
val plaintext: Boolean
val plaintext: Boolean,
val canReadWriteBackupDirectory: Boolean = false
)
enum class BackupState(val inProgress: Boolean = false) {

View file

@ -89,7 +89,7 @@ public class VoiceNotePlaybackService extends MediaSessionService {
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
setListener(new MediaSessionServiceListener());
AppDependencies.getDatabaseObserver().registerAttachmentObserver(attachmentDeletionObserver);
AppDependencies.getDatabaseObserver().registerAttachmentDeletedObserver(attachmentDeletionObserver);
}
@Override

View file

@ -53,6 +53,7 @@ import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireNonNullBlob
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireObject
@ -495,7 +496,7 @@ class AttachmentTable(
return readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE.toString())
.where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE)
.limit(batchSize)
.orderBy("$ID DESC")
.run()
@ -508,7 +509,7 @@ class AttachmentTable(
return readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$REMOTE_KEY IS NOT NULL AND $REMOTE_DIGEST IS NOT NULL AND $TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE.toString())
.where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE)
.limit(batchSize)
.orderBy("$ID DESC")
.run()
@ -517,17 +518,17 @@ class AttachmentTable(
attachmentId = AttachmentId(it.requireLong(ID)),
mmsId = it.requireLong(MESSAGE_ID),
size = it.requireLong(DATA_SIZE),
remoteDigest = it.requireBlob(REMOTE_DIGEST)!!,
remoteKey = it.requireBlob(REMOTE_KEY)!!
remoteDigest = it.requireBlob(REMOTE_DIGEST),
remoteKey = it.requireBlob(REMOTE_KEY)
)
}
}
fun getTotalRestorableAttachmentSize(): Long {
fun getRemainingRestorableAttachmentSize(): Long {
return readableDatabase
.select("SUM($DATA_SIZE)")
.from(TABLE_NAME)
.where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE.toString())
.where("$TRANSFER_STATE = ? OR $TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE, TRANSFER_RESTORE_IN_PROGRESS)
.run()
.readToSingleLong()
}
@ -628,7 +629,7 @@ class AttachmentTable(
.where("$MESSAGE_ID = ?", mmsId)
.run()
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
deleteCount > 0
}
@ -692,7 +693,7 @@ class AttachmentTable(
.where("$MESSAGE_ID = ?", messageId)
.run()
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
val threadId = messages.getThreadIdForMessage(messageId)
if (threadId > 0) {
@ -729,7 +730,7 @@ class AttachmentTable(
.run()
deleteDataFileIfPossible(data, contentType, id)
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
}
}
}
@ -849,7 +850,7 @@ class AttachmentTable(
FileUtils.deleteDirectoryContents(context.getDir(DIRECTORY, Context.MODE_PRIVATE))
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
}
fun setTransferState(messageId: Long, attachmentId: AttachmentId, transferState: Int) {
@ -874,7 +875,6 @@ class AttachmentTable(
notifyConversationListeners(threadId)
}
@Throws(MmsException::class)
fun setTransferProgressFailed(attachmentId: AttachmentId, mmsId: Long) {
writableDatabase
.update(TABLE_NAME)
@ -885,7 +885,6 @@ class AttachmentTable(
notifyConversationListeners(messages.getThreadIdForMessage(mmsId))
}
@Throws(MmsException::class)
fun setThumbnailRestoreProgressFailed(attachmentId: AttachmentId, mmsId: Long) {
writableDatabase
.update(TABLE_NAME)
@ -896,7 +895,6 @@ class AttachmentTable(
notifyConversationListeners(messages.getThreadIdForMessage(mmsId))
}
@Throws(MmsException::class)
fun setTransferProgressPermanentFailure(attachmentId: AttachmentId, mmsId: Long) {
writableDatabase
.update(TABLE_NAME)
@ -907,6 +905,57 @@ class AttachmentTable(
notifyConversationListeners(messages.getThreadIdForMessage(mmsId))
}
fun setRestoreInProgressTransferState(restorableAttachments: List<LocalRestorableAttachment>) {
setRestoreTransferState(
restorableAttachments = restorableAttachments,
prefix = "$TRANSFER_STATE = $TRANSFER_NEEDS_RESTORE",
state = TRANSFER_RESTORE_IN_PROGRESS
)
}
fun setRestoreFailedTransferState(notRestorableAttachments: List<LocalRestorableAttachment>) {
setRestoreTransferState(
restorableAttachments = notRestorableAttachments,
prefix = "$TRANSFER_STATE != $TRANSFER_PROGRESS_PERMANENT_FAILURE",
state = TRANSFER_PROGRESS_FAILED
)
}
private fun setRestoreTransferState(restorableAttachments: List<LocalRestorableAttachment>, prefix: String, state: Int) {
writableDatabase.withinTransaction {
val setQueries = SqlUtil.buildCollectionQuery(
column = ID,
values = restorableAttachments.map { it.attachmentId.id },
prefix = "$prefix AND"
)
setQueries.forEach { query ->
writableDatabase
.update(TABLE_NAME)
.values(TRANSFER_STATE to state)
.where(query.where, query.whereArgs)
.run()
}
val threadQueries = SqlUtil.buildCollectionQuery(
column = MessageTable.ID,
values = restorableAttachments.map { it.mmsId }
)
val threads = mutableSetOf<Long>()
threadQueries.forEach { query ->
threads += readableDatabase
.select("DISTINCT ${MessageTable.THREAD_ID}")
.from(MessageTable.TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToList { it.requireLongOrNull(MessageTable.THREAD_ID) ?: -1 }
}
notifyConversationListeners(threads)
}
}
/**
* When we find out about a new inbound attachment pointer, we insert a row for it that contains all the info we need to download it via [insertAttachmentWithData].
* Later, we download the data for that pointer. Call this method once you have the data to associate it with the attachment. At this point, it is assumed
@ -982,7 +1031,7 @@ class AttachmentTable(
notifyConversationListeners(threadId)
notifyConversationListListeners()
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
if (foundDuplicate) {
if (!fileWriteResult.file.delete()) {
@ -1020,7 +1069,7 @@ class AttachmentTable(
}
notifyConversationListListeners()
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
if (!transferFile.delete()) {
Log.w(TAG, "Unable to delete transfer file.")
@ -1904,7 +1953,7 @@ class AttachmentTable(
AttachmentId(rowId)
}
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
return attachmentId
}
@ -1961,7 +2010,7 @@ class AttachmentTable(
AttachmentId(rowId)
}
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
return attachmentId
}
@ -2101,7 +2150,7 @@ class AttachmentTable(
}
}
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
return attachmentId
}
@ -2460,7 +2509,7 @@ class AttachmentTable(
val attachmentId: AttachmentId,
val mmsId: Long,
val size: Long,
val remoteDigest: ByteArray,
val remoteKey: ByteArray
val remoteDigest: ByteArray?,
val remoteKey: ByteArray?
)
}

View file

@ -1,7 +1,5 @@
package org.thoughtcrime.securesms.database;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
@ -24,7 +22,7 @@ import java.util.concurrent.Executor;
/**
* Allows listening to database changes to varying degrees of specificity.
*
* <p>
* A replacement for the observer system in {@link DatabaseTable}. We should move to this over time.
*/
public class DatabaseObserver {
@ -46,11 +44,10 @@ public class DatabaseObserver {
private static final String KEY_SCHEDULED_MESSAGES = "ScheduledMessages";
private static final String KEY_CONVERSATION_DELETES = "ConversationDeletes";
private static final String KEY_CALL_UPDATES = "CallUpdates";
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
private static final String KEY_CALL_UPDATES = "CallUpdates";
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
private final Application application;
private final Executor executor;
private final Set<Observer> conversationListObservers;
@ -63,7 +60,8 @@ public class DatabaseObserver {
private final Set<Observer> chatColorsObservers;
private final Set<Observer> stickerObservers;
private final Set<Observer> stickerPackObservers;
private final Set<Observer> attachmentObservers;
private final Set<Observer> attachmentUpdatedObservers;
private final Set<Observer> attachmentDeletedObservers;
private final Set<MessageObserver> messageUpdateObservers;
private final Map<Long, Set<MessageObserver>> messageInsertObservers;
private final Set<Observer> notificationProfileObservers;
@ -72,8 +70,7 @@ public class DatabaseObserver {
private final Map<CallLinkRoomId, Set<Observer>> callLinkObservers;
private final Set<InAppPaymentObserver> inAppPaymentObservers;
public DatabaseObserver(Application application) {
this.application = application;
public DatabaseObserver() {
this.executor = new SerialExecutor(SignalExecutors.BOUNDED);
this.conversationListObservers = new HashSet<>();
this.conversationObservers = new HashMap<>();
@ -84,7 +81,8 @@ public class DatabaseObserver {
this.chatColorsObservers = new HashSet<>();
this.stickerObservers = new HashSet<>();
this.stickerPackObservers = new HashSet<>();
this.attachmentObservers = new HashSet<>();
this.attachmentUpdatedObservers = new HashSet<>();
this.attachmentDeletedObservers = new HashSet<>();
this.messageUpdateObservers = new HashSet<>();
this.messageInsertObservers = new HashMap<>();
this.notificationProfileObservers = new HashSet<>();
@ -149,9 +147,15 @@ public class DatabaseObserver {
});
}
public void registerAttachmentObserver(@NonNull Observer listener) {
public void registerAttachmentUpdatedObserver(@NonNull Observer listener) {
executor.execute(() -> {
attachmentObservers.add(listener);
attachmentUpdatedObservers.add(listener);
});
}
public void registerAttachmentDeletedObserver(@NonNull Observer listener) {
executor.execute(() -> {
attachmentDeletedObservers.add(listener);
});
}
@ -211,7 +215,8 @@ public class DatabaseObserver {
chatColorsObservers.remove(listener);
stickerObservers.remove(listener);
stickerPackObservers.remove(listener);
attachmentObservers.remove(listener);
attachmentUpdatedObservers.remove(listener);
attachmentDeletedObservers.remove(listener);
notificationProfileObservers.remove(listener);
unregisterMapped(storyObservers, listener);
unregisterMapped(scheduledMessageObservers, listener);
@ -307,9 +312,16 @@ public class DatabaseObserver {
});
}
public void notifyAttachmentObservers() {
public void notifyAttachmentUpdatedObservers() {
runPostSuccessfulTransaction(KEY_ATTACHMENTS, () -> {
notifySet(attachmentObservers);
notifySet(attachmentUpdatedObservers);
});
}
public void notifyAttachmentDeletedObservers() {
runPostSuccessfulTransaction(KEY_ATTACHMENTS, () -> {
notifySet(attachmentDeletedObservers);
notifySet(attachmentUpdatedObservers);
});
}

View file

@ -71,10 +71,6 @@ public abstract class DatabaseTable {
AppDependencies.getDatabaseObserver().notifyStickerObservers();
}
protected void notifyAttachmentListeners() {
AppDependencies.getDatabaseObserver().notifyAttachmentObservers();
}
public void reset(SignalDatabase databaseHelper) {
this.databaseHelper = databaseHelper;
}

View file

@ -6,7 +6,7 @@ import android.database.Cursor
import androidx.compose.runtime.Immutable
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.MediaUtil
@ -159,6 +159,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
)
)"""
)
private fun applyEqualityOperator(threadId: Long, query: String): String {
return query.replace("__EQUALITY__", if (threadId == ALL_THREADS.toLong()) "!=" else "=")
}
@ -207,7 +208,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
readableDatabase.rawQuery(UNIQUE_MEDIA_QUERY, null).use { cursor ->
while (cursor.moveToNext()) {
val size: Int = cursor.requireInt(AttachmentTable.DATA_SIZE)
val type: String = cursor.requireNonNullString(AttachmentTable.CONTENT_TYPE)
val type: String? = cursor.requireString(AttachmentTable.CONTENT_TYPE)
when (MediaUtil.getSlideTypeFromContentType(type)) {
SlideType.GIF,
@ -215,17 +216,21 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
SlideType.MMS -> {
photoSize += size.toLong()
}
SlideType.VIDEO -> {
videoSize += size.toLong()
}
SlideType.AUDIO -> {
audioSize += size.toLong()
}
SlideType.LONG_TEXT,
SlideType.DOCUMENT -> {
documentSize += size.toLong()
}
else -> {}
SlideType.VIEW_ONCE -> Unit
}
}
}

View file

@ -2115,7 +2115,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
AppDependencies.databaseObserver.notifyConversationListListeners()
if (deletedAttachments) {
AppDependencies.databaseObserver.notifyAttachmentObservers()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
}
}

View file

@ -361,7 +361,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
MultiDeviceDeleteSyncJob.enqueueThreadDeletes(threadTrimsToSync, isFullDelete = false)
}
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
notifyStickerPackListeners()
OptimizeMessageSearchIndexJob.enqueue()
}
@ -395,7 +395,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
MultiDeviceDeleteSyncJob.enqueueThreadDeletes(listOf(threadTrimToSync!!), isFullDelete = false)
}
notifyAttachmentListeners()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
notifyStickerPackListeners()
OptimizeMessageSearchIndexJob.enqueue()
}

View file

@ -71,7 +71,7 @@ public final class GroupedThreadMediaLoader extends AsyncTaskLoader<GroupedThrea
PopulatedGroupedThreadMedia mediaGrouping = new PopulatedGroupedThreadMedia(groupingMethod);
AppDependencies.getDatabaseObserver().registerAttachmentObserver(observer);
AppDependencies.getDatabaseObserver().registerAttachmentUpdatedObserver(observer);
try (Cursor cursor = ThreadMediaLoader.createThreadMediaCursor(context, threadId, mediaType, sorting)) {
while (cursor != null && cursor.moveToNext()) {

View file

@ -255,7 +255,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
@Override
public @NonNull DatabaseObserver provideDatabaseObserver() {
return new DatabaseObserver(context);
return new DatabaseObserver();
}
@SuppressWarnings("ConstantConditions")

View file

@ -175,11 +175,7 @@ public final class AttachmentCompressionJob extends BaseJob {
return;
}
try {
database.setTransferProgressFailed(attachmentId, databaseAttachment.mmsId);
} catch (MmsException e) {
Log.w(TAG, "Error marking attachment as failed upon failed compression.", e);
}
database.setTransferProgressFailed(attachmentId, databaseAttachment.mmsId);
}
@Override

View file

@ -425,19 +425,11 @@ class AttachmentDownloadJob private constructor(
}
private fun markFailed(messageId: Long, attachmentId: AttachmentId) {
try {
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
} catch (e: MmsException) {
Log.w(TAG, e)
}
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
}
private fun markPermanentlyFailed(messageId: Long, attachmentId: AttachmentId) {
try {
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId)
} catch (e: MmsException) {
Log.w(TAG, e)
}
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId)
}
@VisibleForTesting

View file

@ -27,7 +27,6 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec
import org.thoughtcrime.securesms.jobs.protos.AttachmentUploadJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.net.NotPushRegisteredException
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.AttachmentProgressService
@ -235,11 +234,7 @@ class AttachmentUploadJob private constructor(
return
}
try {
database.setTransferProgressFailed(attachmentId, databaseAttachment.mmsId)
} catch (e: MmsException) {
Log.w(TAG, "Error marking attachment as failed upon failed/canceled upload.", e)
}
database.setTransferProgressFailed(attachmentId, databaseAttachment.mmsId)
}
override fun onShouldRetry(exception: Exception): Boolean {

View file

@ -48,7 +48,7 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo
throw NotPushRegisteredException()
}
SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getTotalRestorableAttachmentSize()
SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
val jobManager = AppDependencies.jobManager
val batchSize = 100
val restoreTime = System.currentTimeMillis()

View file

@ -215,6 +215,7 @@ public final class JobManagerFactories {
put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory());
put(RestoreAttachmentJob.KEY, new RestoreAttachmentJob.Factory());
put(RestoreAttachmentThumbnailJob.KEY, new RestoreAttachmentThumbnailJob.Factory());
put(RestoreLocalAttachmentJob.KEY, new RestoreLocalAttachmentJob.Factory());
put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory());
put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory());
put(RetrieveRemoteAnnouncementsJob.KEY, new RetrieveRemoteAnnouncementsJob.Factory());

View file

@ -58,7 +58,7 @@ class RestoreAttachmentJob private constructor(
companion object {
const val KEY = "RestoreAttachmentJob"
val TAG = Log.tag(AttachmentDownloadJob::class.java)
val TAG = Log.tag(RestoreAttachmentJob::class.java)
private const val KEY_MESSAGE_ID = "message_id"
private const val KEY_ATTACHMENT_ID = "part_row_id"
@ -153,7 +153,7 @@ class RestoreAttachmentJob private constructor(
AppDependencies.messageNotifier.updateNotification(context, forConversation(0))
}
if (SignalDatabase.attachments.getTotalRestorableAttachmentSize() == 0L) {
if (SignalDatabase.attachments.getRemainingRestorableAttachmentSize() == 0L) {
SignalStore.backup.totalRestorableAttachmentSize = 0L
}
}
@ -483,11 +483,7 @@ class RestoreAttachmentJob private constructor(
}
private fun markPermanentlyFailed(messageId: Long, attachmentId: AttachmentId) {
try {
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId)
} catch (e: MmsException) {
Log.w(TAG, e)
}
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId)
}
@VisibleForTesting

View file

@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.jobmanager.JobLogger.format
import org.thoughtcrime.securesms.jobmanager.JsonJobData
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forConversation
import org.thoughtcrime.securesms.transport.RetryLaterException
import org.thoughtcrime.securesms.util.RemoteConfig
@ -232,11 +231,7 @@ class RestoreAttachmentThumbnailJob private constructor(
}
private fun markFailed(messageId: Long, attachmentId: AttachmentId) {
try {
SignalDatabase.attachments.setThumbnailRestoreProgressFailed(attachmentId, messageId)
} catch (e: MmsException) {
Log.w(TAG, e)
}
SignalDatabase.attachments.setThumbnailRestoreProgressFailed(attachmentId, messageId)
}
@VisibleForTesting

View file

@ -0,0 +1,184 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import org.signal.core.util.Base64
import org.signal.core.util.androidx.DocumentFileInfo
import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.signal.libsignal.protocol.InvalidMacException
import org.signal.libsignal.protocol.InvalidMessageException
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.LocalRestorableAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.RestoreLocalAttachmentJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.MmsException
import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream.StreamSupplier
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Restore attachment from local backup storage.
*/
class RestoreLocalAttachmentJob private constructor(
parameters: Parameters,
private val attachmentId: AttachmentId,
private val messageId: Long,
private val restoreUri: Uri,
private val size: Long
) : Job(parameters) {
companion object {
const val KEY = "RestoreLocalAttachmentJob"
val TAG = Log.tag(RestoreLocalAttachmentJob::class.java)
fun enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo: Map<String, DocumentFileInfo>) {
var restoreAttachmentJobs: MutableList<Job>
do {
val possibleRestorableAttachments: List<LocalRestorableAttachment> = SignalDatabase.attachments.getLocalRestorableAttachments(500)
val restorableAttachments = ArrayList<LocalRestorableAttachment>(possibleRestorableAttachments.size)
val notRestorableAttachments = ArrayList<LocalRestorableAttachment>(possibleRestorableAttachments.size)
restoreAttachmentJobs = ArrayList(possibleRestorableAttachments.size)
possibleRestorableAttachments
.forEachIndexed { index, attachment ->
val fileInfo = if (attachment.remoteKey != null && attachment.remoteDigest != null) {
val mediaName = MediaName.fromDigest(attachment.remoteDigest).name
mediaNameToFileInfo[mediaName]
} else {
null
}
if (fileInfo != null) {
restorableAttachments += attachment
restoreAttachmentJobs += RestoreLocalAttachmentJob("RestoreLocalAttachmentJob_${index % 2}", attachment, fileInfo)
} else {
notRestorableAttachments += attachment
}
}
SignalDatabase.rawDatabase.withinTransaction {
SignalDatabase.attachments.setRestoreInProgressTransferState(restorableAttachments)
SignalDatabase.attachments.setRestoreFailedTransferState(notRestorableAttachments)
SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize()
AppDependencies.jobManager.addAll(restoreAttachmentJobs)
}
} while (restoreAttachmentJobs.isNotEmpty())
}
}
private constructor(queue: String, attachment: LocalRestorableAttachment, info: DocumentFileInfo) : this(
Parameters.Builder()
.setQueue(queue)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
attachmentId = attachment.attachmentId,
messageId = attachment.mmsId,
restoreUri = info.documentFile.uri,
size = info.size
)
override fun serialize(): ByteArray? {
return RestoreLocalAttachmentJobData(
attachmentId = attachmentId.id,
messageId = messageId,
fileUri = restoreUri.toString(),
fileSize = size
).encode()
}
override fun getFactoryKey(): String {
return KEY
}
override fun run(): Result {
Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId")
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)
if (attachment == null) {
Log.w(TAG, "attachment no longer exists.")
return Result.failure()
}
if (attachment.remoteDigest == null || attachment.remoteKey == null) {
Log.w(TAG, "Attachment no longer has a remote digest or key")
return Result.failure()
}
if (attachment.isPermanentlyFailed) {
Log.w(TAG, "Attachment was marked as a permanent failure. Refusing to download.")
return Result.failure()
}
if (attachment.transferState != AttachmentTable.TRANSFER_NEEDS_RESTORE && attachment.transferState != AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS) {
Log.w(TAG, "Attachment does not need to be restored.")
return Result.success()
}
val combinedKey = Base64.decode(attachment.remoteKey)
val streamSupplier = StreamSupplier { ArchiveFileSystem.openInputStream(context, restoreUri) ?: throw IOException("Unable to open stream") }
try {
// TODO [local-backup] actually verify mac and save iv
AttachmentCipherInputStream.createForAttachment(streamSupplier, size, attachment.size, combinedKey, null, null, 0, true).use { input ->
SignalDatabase.attachments.finalizeAttachmentAfterDownload(attachment.mmsId, attachment.attachmentId, input, null)
}
} catch (e: InvalidMessageException) {
Log.w(TAG, "Experienced an InvalidMessageException while trying to read attachment.", e)
if (e.cause is InvalidMacException) {
Log.w(TAG, "Detected an invalid mac. Treating as a permanent failure.")
markPermanentlyFailed(messageId, attachmentId)
}
return Result.failure()
} catch (e: MmsException) {
Log.w(TAG, "Experienced exception while trying to store attachment.", e)
return Result.failure()
} catch (e: IOException) {
Log.w(TAG, "Experienced an exception while trying to read attachment.", e)
return Result.retry(defaultBackoff())
}
return Result.success()
}
override fun onFailure() {
markFailed(messageId, attachmentId)
AppDependencies.databaseObserver.notifyAttachmentUpdatedObservers()
}
private fun markFailed(messageId: Long, attachmentId: AttachmentId) {
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
}
private fun markPermanentlyFailed(messageId: Long, attachmentId: AttachmentId) {
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId)
}
class Factory : Job.Factory<RestoreLocalAttachmentJob?> {
override fun create(parameters: Parameters, serializedData: ByteArray?): RestoreLocalAttachmentJob {
val data = RestoreLocalAttachmentJobData.ADAPTER.decode(serializedData!!)
return RestoreLocalAttachmentJob(
parameters = parameters,
attachmentId = AttachmentId(data.attachmentId),
messageId = data.messageId,
restoreUri = Uri.parse(data.fileUri),
size = data.fileSize
)
}
}
}

View file

@ -152,7 +152,7 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v
val threadId = args.threadId
viewModel.fetchAttachments(requireContext(), startingAttachmentId, threadId, sorting)
val dbObserver = DatabaseObserver.Observer { viewModel.fetchAttachments(requireContext(), startingAttachmentId, threadId, sorting, true) }
AppDependencies.databaseObserver.registerAttachmentObserver(dbObserver)
AppDependencies.databaseObserver.registerAttachmentUpdatedObserver(dbObserver)
this.dbChangeObserver = dbObserver
}

View file

@ -76,7 +76,7 @@ public class MediaUtil {
public static final String UNKNOWN = "*/*";
public static final String OCTET = "application/octet-stream";
public static SlideType getSlideTypeFromContentType(@Nullable String contentType) {
public static @NonNull SlideType getSlideTypeFromContentType(@Nullable String contentType) {
if (isGif(contentType)) {
return SlideType.GIF;
} else if (isImageType(contentType)) {

View file

@ -117,4 +117,11 @@ message GroupCallPeekJobData {
uint64 groupRecipientId = 1;
uint64 senderRecipientId = 2;
uint64 serverTimestamp = 3;
}
}
message RestoreLocalAttachmentJobData {
uint64 attachmentId = 1;
uint64 messageId = 2;
string fileUri = 3;
uint64 fileSize = 4;
}

View file

@ -11,6 +11,7 @@ import androidx.documentfile.provider.DocumentFile
import androidx.documentfile.provider.isTreeDocumentFile
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
@ -29,7 +30,7 @@ object DocumentFileUtil {
private const val FILE_SELECTION = "${DocumentsContract.Document.COLUMN_DISPLAY_NAME} = ?"
private const val LIST_FILES_SELECTION = "${DocumentsContract.Document.COLUMN_MIME_TYPE} != ?"
private val LIST_FILES_SELECTION_ARS = arrayOf(DocumentsContract.Document.MIME_TYPE_DIR)
private val LIST_FILES_SELECTION_ARGS = arrayOf(DocumentsContract.Document.MIME_TYPE_DIR)
private const val MAX_STORAGE_ATTEMPTS: Int = 5
private val WAIT_FOR_SCOPED_STORAGE: LongArray = longArrayOf(0, 2.seconds.inWholeMilliseconds, 10.seconds.inWholeMilliseconds, 20.seconds.inWholeMilliseconds, 30.seconds.inWholeMilliseconds)
@ -83,36 +84,34 @@ object DocumentFileUtil {
* If direct queries fail to find the file, will fallback to using [DocumentFile.findFile].
*/
fun DocumentFile.findFile(context: Context, fileName: String): DocumentFileInfo? {
val child = if (isTreeDocumentFile()) {
val child: List<DocumentFileInfo> = if (isTreeDocumentFile()) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, DocumentsContract.getDocumentId(uri))
try {
context
.contentResolver
.query(childrenUri, FILE_PROJECTION, FILE_SELECTION, arrayOf(fileName), null)
?.use { cursor ->
if (cursor.count == 1) {
cursor.moveToFirst()
val uri = DocumentsContract.buildDocumentUriUsingTree(uri, cursor.requireString(DocumentsContract.Document.COLUMN_DOCUMENT_ID))
val displayName = cursor.requireNonNullString(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val length = cursor.requireLong(DocumentsContract.Document.COLUMN_SIZE)
?.readToList(predicate = { it.name == fileName }) { cursor ->
val uri = DocumentsContract.buildDocumentUriUsingTree(uri, cursor.requireString(DocumentsContract.Document.COLUMN_DOCUMENT_ID))
val displayName = cursor.requireNonNullString(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val length = cursor.requireLong(DocumentsContract.Document.COLUMN_SIZE)
DocumentFileInfo(DocumentFile.fromSingleUri(context, uri)!!, displayName, length)
} else {
val message = if (cursor.count > 1) "Multiple files" else "No files"
Log.w(TAG, "$message returned with same name")
null
}
}
DocumentFileInfo(DocumentFile.fromSingleUri(context, uri)!!, displayName, length)
} ?: emptyList()
} catch (e: Exception) {
Log.d(TAG, "Unable to find file directly on ${javaClass.simpleName}, falling back to OS", e)
null
emptyList()
}
} else {
null
emptyList()
}
return child ?: this.findFile(fileName)?.let { DocumentFileInfo(it, it.name!!, it.length()) }
return if (child.size == 1) {
child[0]
} else {
Log.w(TAG, "Did not find single file, found (${child.size}), falling back to OS")
this.findFile(fileName)?.let { DocumentFileInfo(it, it.name!!, it.length()) }
}
}
/**
@ -128,7 +127,7 @@ object DocumentFileUtil {
try {
val results = context
.contentResolver
.query(childrenUri, FILE_PROJECTION, LIST_FILES_SELECTION, LIST_FILES_SELECTION_ARS, null)
.query(childrenUri, FILE_PROJECTION, LIST_FILES_SELECTION, LIST_FILES_SELECTION_ARGS, null)
?.use { cursor ->
val results = ArrayList<DocumentFileInfo>(cursor.count)
while (cursor.moveToNext()) {