Compare commits

..

14 Commits

Author SHA1 Message Date
Michelle Tang 9b9f59f1b0 Bump version to 8.21.1 2026-07-28 15:54:07 -04:00
Michelle Tang b71e35de40 Update baseline profile. 2026-07-28 15:48:46 -04:00
Michelle Tang 3685edb071 Update translations and other static files. 2026-07-28 15:42:11 -04:00
Greyson Parrelli aaa497ff29 Fix bottom chrome megaphone clipped behind navigation bar in rail layouts. 2026-07-28 15:01:13 -04:00
Greyson Parrelli 9e719129a8 Fix media review edge-to-edge bug. 2026-07-28 14:55:44 -04:00
Greyson Parrelli 1bf1bbc7ed Fix media gallery fragment issues in landscape. 2026-07-28 14:55:39 -04:00
Greyson Parrelli 0599c9de87 Fix edge-to-edge issue with forward sheet. 2026-07-28 14:55:33 -04:00
Greyson Parrelli a50dfd37ad Fix scroll issue in forward sheet. 2026-07-28 14:55:28 -04:00
Greyson Parrelli 1b206ddc70 Fix various edge to edge bugs. 2026-07-28 14:55:23 -04:00
Greyson Parrelli a9a8ec7f5d Remove edge-to-edge opt-out. 2026-07-28 14:55:15 -04:00
Greyson Parrelli 47a6b34e17 Add StorageSyncJob tests. 2026-07-28 13:51:24 -04:00
Greyson Parrelli 5f9b9d2d3a Fix crash with unknown sticker packs. 2026-07-28 12:22:56 -04:00
Michelle Tang 5c0fbdc8b5 Fix expiring issues. 2026-07-28 11:54:51 -04:00
Greyson Parrelli 15d95a45c9 Remove dependabot config. 2026-07-27 16:24:50 -04:00
37 changed files with 2455 additions and 1265 deletions
-30
View File
@@ -1,30 +0,0 @@
# Dependabot automated schedule is currently disabled.
# To re-enable, uncomment the block below.
#
# version: 2
# updates:
# # Automatically keep GitHub Actions SHA-pinned to the latest commit SHAs.
# # Dependabot will update both the SHA and the inline version comment (e.g. # v6)
# # while leaving any extra documentation comments intact.
# - package-ecosystem: "github-actions"
# directory: "/"
# schedule:
# interval: "weekly"
# day: "monday"
# labels:
# - "dependencies"
# commit-message:
# prefix: "ci"
# groups:
# actions:
# patterns:
# - "actions/*"
# gradle-actions:
# patterns:
# - "gradle/*"
# peter-evans:
# patterns:
# - "peter-evans/*"
# usefulness:
# patterns:
# - "usefulness/*"
+2 -2
View File
@@ -32,8 +32,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1725
val canonicalVersionName = "8.21.0"
val canonicalVersionCode = 1726
val canonicalVersionName = "8.21.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,12 @@ abstract class DSLSettingsFragment(
private var toolbar: Toolbar? = null
/**
* Set by layouts that anchor the list to the top of the toolbar rather than below it. Those lists scroll
* behind the toolbar, so they have to carry the status bar inset themselves.
*/
protected open val listScrollsBehindToolbar: Boolean = false
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
toolbar = view.findViewById(R.id.toolbar)
@@ -106,8 +112,10 @@ abstract class DSLSettingsFragment(
}
recyclerView?.let { recycler ->
val insetTypes = WindowInsetsCompat.Type.navigationBars() or if (listScrollsBehindToolbar) WindowInsetsCompat.Type.statusBars() else 0
recycler.clipToPadding = false
SystemWindowInsetsSetter.attach(recycler, viewLifecycleOwner, WindowInsetsCompat.Type.navigationBars())
SystemWindowInsetsSetter.attach(recycler, viewLifecycleOwner, insetTypes)
}
}
@@ -140,6 +140,8 @@ class ConversationSettingsFragment :
menuId = R.menu.conversation_settings
) {
override val listScrollsBehindToolbar: Boolean = true
private val args: ConversationSettingsFragmentArgs by navArgs()
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
private val alertDisabledTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary_50) }
@@ -19,7 +19,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -138,7 +140,7 @@ fun ContactSearch(
userScrollEnabled = !isDisplayingContextMenu,
fastScrollerState = fastScrollerState,
lazyListState = lazyListState,
modifier = modifier,
modifier = modifier.nestedScroll(rememberNestedScrollInteropConnection()),
letterContent = {
Emojifier(text = it.toString()) { annotatedText, inlineContent ->
Text(
@@ -154,7 +156,7 @@ fun ContactSearch(
userScrollEnabled = !isDisplayingContextMenu,
controller = mappingCtrl,
lazyListState = it,
modifier = modifier
modifier = Modifier.fillMaxSize()
)
}
}
@@ -18,12 +18,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.fragment.app.FragmentManager
import kotlinx.collections.immutable.persistentHashMapOf
import kotlinx.coroutines.flow.filter
@@ -159,7 +157,7 @@ class ContactSearchView : AbstractComposeView {
longClickCallbacks = currentLongClickCallbacks ?: rememberDefaultContactSearchItemLongClickCallbacks(),
storyContextMenuCallbacks = currentStoryContextMenuCallbacks ?: rememberDefaultContactSearchItemStoryContextMenuCallbacks(vm),
callButtonClickCallbacks = currentCallButtonClickCallbacks ?: rememberDefaultContactSearchItemCallButtonClickCallbacks(),
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()).fillMaxSize()
modifier = Modifier.fillMaxSize()
)
}
}
@@ -22,10 +22,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
@@ -132,7 +134,9 @@ class MultiselectForwardFragment :
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
private var handler: Handler? = null
/** Height of the bottom bar's content, excluding any window inset padding it applies. */
private var bottomBarHeightPx by mutableIntStateOf(0)
private var isBottomBarVisible by mutableStateOf(false)
private val args: MultiselectForwardFragmentArgs by lazy {
requireArguments().getParcelableCompat(ARGS, MultiselectForwardFragmentArgs::class.java)!!
@@ -157,7 +161,7 @@ class MultiselectForwardFragment :
mapStateToConfiguration = this@MultiselectForwardFragment::getConfiguration,
contactSearchCallbacks = remember { SearchCallbacks() },
additionalEntries = findListener<SearchConfigurationProvider>()?.getAdditionalEntries() ?: persistentHashMapOf(),
bottomContentPadding = with(LocalDensity.current) { bottomBarHeightPx.toDp() }
bottomContentPadding = with(LocalDensity.current) { (if (isBottomBarVisible) bottomBarHeightPx else 0).toDp() }
)
}
}
@@ -203,9 +207,8 @@ class MultiselectForwardFragment :
isSplitPane = isSplitPane,
modifier = Modifier
.fillMaxWidth()
// Bottom-sheet hosts already clear the navigation bar via BottomSheetBehavior's
// edge-to-edge padding; padding again here would double up.
.then(if (args.isWrappedInBottomSheet) Modifier else Modifier.navigationBarsPadding())
.navigationBarsPadding()
.onSizeChanged { bottomBarHeightPx = it.height }
)
}
}
@@ -217,10 +220,6 @@ class MultiselectForwardFragment :
bottomBar.visible = false
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
bottomBarHeightPx = if (bottomBar.isVisible) bottom - top else 0
}
container.addView(bottomBar)
viewLifecycleOwner.lifecycleScope.launch {
@@ -247,13 +246,14 @@ class MultiselectForwardFragment :
if (contactSelection.isNotEmpty() && !bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_from_bottom)
bottomBar.visible = true
isBottomBarVisible = true
if (args.forceDisableAddMessage) {
ViewUtil.hideKeyboard(requireContext(), bottomBar)
}
} else if (contactSelection.isEmpty() && bottomBar.isVisible) {
bottomBar.animation = AnimationUtils.loadAnimation(requireContext(), R.anim.slide_fade_to_bottom)
bottomBar.visible = false
bottomBarHeightPx = 0
isBottomBarVisible = false
}
}
}
@@ -5705,12 +5705,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val threads: MutableList<Long> = LinkedList()
readableDatabase
.select(ID, THREAD_ID, EXPIRES_IN, EXPIRE_STARTED, LATEST_REVISION_ID)
.select(ID, TYPE, THREAD_ID, EXPIRES_IN, EXPIRE_STARTED, LATEST_REVISION_ID)
.from(TABLE_NAME)
.where("$DATE_SENT = ? AND ($FROM_RECIPIENT_ID = ? OR ($FROM_RECIPIENT_ID = ? AND $outgoingTypeClause))", messageId.timetamp, messageId.recipientId, Recipient.self().id)
.run()
.forEach { cursor ->
val id = cursor.requireLong(ID)
val type = cursor.requireLong(TYPE)
val threadId = cursor.requireLong(THREAD_ID)
val expiresIn = cursor.requireLong(EXPIRES_IN)
val expireStarted = cursor.requireLong(EXPIRE_STARTED).let {
@@ -5731,7 +5732,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
VOTES_LAST_SEEN to System.currentTimeMillis()
)
if (expiresIn > 0) {
if (expiresIn > 0 && !MessageTypes.isExpirationTimerUpdate(type)) {
values.put(EXPIRE_STARTED, expireStarted)
expiring += Pair(id, expiresIn)
}
@@ -374,6 +374,30 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
return false
}
val knownTypes = getKnownTypes()
val knownUnknownIds = SignalDatabase.unknownStorageIds.getAllWithTypes(knownTypes)
if (knownUnknownIds.isNotEmpty()) {
Log.i(TAG, "We have ${knownUnknownIds.size} unknown records that we can now process.")
val remote = when (val result = repository.readStorageRecords(storageServiceKey, remoteManifest.recordIkm, knownUnknownIds)) {
is StorageServiceService.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.DecryptionError -> throw result.exception
is StorageServiceService.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceService.StorageRecordResult.StatusCodeError -> throw result.exception
}
val records = StorageRecordCollection(remote)
Log.i(TAG, "Found ${remote.size} of the known-unknowns remotely.")
db.withinTransaction {
processKnownRecords(context, records)
SignalDatabase.unknownStorageIds.deleteAllWithTypes(knownTypes)
}
}
stopwatch.split("known-unknowns")
val remoteWriteOperation: WriteOperationResult = db.withinTransaction {
self = freshSelf()
@@ -433,33 +457,6 @@ class StorageSyncJob private constructor(parameters: Parameters, private var loc
Log.i(TAG, "No remote writes needed. Still at version: " + remoteManifest.versionString)
}
val knownTypes = getKnownTypes()
val knownUnknownIds = SignalDatabase.unknownStorageIds.getAllWithTypes(knownTypes)
if (knownUnknownIds.isNotEmpty()) {
Log.i(TAG, "We have ${knownUnknownIds.size} unknown records that we can now process.")
val remote = when (val result = repository.readStorageRecords(storageServiceKey, remoteManifest.recordIkm, knownUnknownIds)) {
is StorageServiceService.StorageRecordResult.Success -> result.records
is StorageServiceService.StorageRecordResult.DecryptionError -> throw result.exception
is StorageServiceService.StorageRecordResult.NetworkError -> throw result.exception
is StorageServiceService.StorageRecordResult.StatusCodeError -> throw result.exception
}
val records = StorageRecordCollection(remote)
Log.i(TAG, "Found ${remote.size} of the known-unknowns remotely.")
db.withinTransaction {
processKnownRecords(context, records)
SignalDatabase.unknownStorageIds.deleteAllWithTypes(knownTypes)
}
Log.i(TAG, "Enqueueing a storage sync job to handle any possible merges after applying unknown records.")
AppDependencies.jobManager.add(StorageSyncJob.forLocalChange())
}
stopwatch.split("known-unknowns")
if (needsForcePush && SignalStore.account.isPrimaryDevice) {
Log.w(TAG, "Scheduling a force push.")
AppDependencies.jobManager.add(StorageForcePushJob())
@@ -72,6 +72,7 @@ fun MainBottomChrome(
modifier = modifier
.fillMaxWidth()
.animateContentSize()
.then(if (navigationType == NavigationType.RAIL) Modifier.navigationBarsPadding() else Modifier)
) {
if (state.mainToolbarMode == MainToolbarMode.FULL && navigationType != NavigationType.RAIL) {
Box(
@@ -97,7 +98,7 @@ fun MainBottomChrome(
return@Column
}
val snackBarModifier = if (state.mainToolbarMode == MainToolbarMode.BASIC) {
val snackBarModifier = if (state.mainToolbarMode == MainToolbarMode.BASIC && navigationType != NavigationType.RAIL) {
Modifier.navigationBarsPadding()
} else {
Modifier
@@ -7,8 +7,12 @@ import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.marginEnd
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.core.view.updatePaddingRelative
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
@@ -29,7 +33,6 @@ import org.thoughtcrime.securesms.databinding.V2MediaGalleryFragmentBinding
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.fragments.requireListener
@@ -73,15 +76,7 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
callbacks = requireListener()
val binding = V2MediaGalleryFragmentBinding.bind(view)
SystemWindowInsetsSetter.attach(view, viewLifecycleOwner, WindowInsetsCompat.Type.navigationBars())
binding.mediaGalleryToolbar.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = ViewUtil.getStatusBarHeight(view)
}
binding.mediaGalleryStatusBarBackground.updateLayoutParams {
height = ViewUtil.getStatusBarHeight(view)
}
applyWindowInsets(view, binding)
binding.mediaGalleryGrid.layoutManager = object : GridLayoutManager(requireContext(), SPAN_COUNT) {
override fun canScrollVertically() = shouldEnableScrolling
@@ -269,6 +264,52 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
}
/**
* The top bar spans the full width so its background covers the cutout, while everything that holds
* content is inset horizontally. Driven off the inset dispatch rather than read once, since our hosts
* declare `configChanges="orientation"` and never recreate this view on rotation.
*/
private fun applyWindowInsets(view: View, binding: V2MediaGalleryFragmentBinding) {
val rootPaddingBottom = view.paddingBottom
val toolbarPaddingLeft = binding.mediaGalleryToolbar.paddingLeft
val toolbarPaddingRight = binding.mediaGalleryToolbar.paddingRight
val managePaddingLeft = binding.mediaGalleryManageContainer.paddingLeft
val managePaddingRight = binding.mediaGalleryManageContainer.paddingRight
val selectedPaddingStart = binding.mediaGallerySelected.paddingStart
val countButtonMarginEnd = binding.mediaGalleryCountButton.marginEnd
ViewCompat.setOnApplyWindowInsetsListener(view) { root, windowInsets ->
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
val isLtr = ViewUtil.isLtr(root)
val startInset = if (isLtr) insets.left else insets.right
val endInset = if (isLtr) insets.right else insets.left
root.updatePadding(bottom = rootPaddingBottom + insets.bottom)
binding.mediaGalleryStatusBarBackground.updateLayoutParams {
height = insets.top
}
binding.mediaGalleryToolbar.updateLayoutParams<ConstraintLayout.LayoutParams> {
topMargin = insets.top
}
binding.mediaGalleryToolbar.updatePadding(left = toolbarPaddingLeft + insets.left, right = toolbarPaddingRight + insets.right)
binding.mediaGalleryManageContainer.updatePadding(left = managePaddingLeft + insets.left, right = managePaddingRight + insets.right)
binding.mediaGalleryGrid.updatePadding(left = insets.left, right = insets.right)
binding.mediaGalleryMissingPermissions.updatePadding(left = insets.left, right = insets.right)
binding.mediaGallerySelected.updatePaddingRelative(start = selectedPaddingStart + startInset)
binding.mediaGalleryCountButton.updateLayoutParams<ConstraintLayout.LayoutParams> {
marginEnd = countButtonMarginEnd + endInset
}
windowInsets
}
view.post { ViewCompat.requestApplyInsets(view) }
}
override fun onResume() {
super.onResume()
refreshMediaGallery()
@@ -22,6 +22,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -126,7 +127,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
SystemWindowInsetsSetter.attach(view, viewLifecycleOwner)
SystemWindowInsetsSetter.attach(view, viewLifecycleOwner, WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
disposables.bindTo(viewLifecycleOwner)
@@ -2027,8 +2027,8 @@ object SyncMessageProcessor {
SignalDatabase.messages.markAsSent(messageId)
if (expiresInMillis > 0) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: sent.timestamp!!)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: sent.timestamp!!, expiresInMillis)
}
return threadId
@@ -2094,8 +2094,8 @@ object SyncMessageProcessor {
log(envelope.clientTimestamp!!, "Inserted sync poll end message as messageId $messageId")
if (expiresInMillis > 0) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: sent.timestamp!!)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: sent.timestamp!!, expiresInMillis)
}
return threadId
@@ -2161,8 +2161,8 @@ object SyncMessageProcessor {
log(envelope.clientTimestamp!!, "Inserted sync pin message as messageId $messageId")
if (expiresInMillis > 0) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: sent.timestamp!!)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: sent.timestamp!!, expiresInMillis)
}
return threadId
@@ -16,6 +16,7 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
@@ -37,6 +38,7 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageCategoriesAdap
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView;
import org.thoughtcrime.securesms.reactions.ReactionsRepository;
import org.thoughtcrime.securesms.reactions.edit.EditReactionsActivity;
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
@@ -214,6 +216,10 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends FixedRound
container.addView(tabBar);
// The tab bar is pinned to the bottom of the dialog window rather than the sheet, so it is not covered by
// the sheet's own inset padding.
SystemWindowInsetsSetter.attach(tabBar.findViewById(R.id.emoji_categories_row), getViewLifecycleOwner(), WindowInsetsCompat.Type.navigationBars());
emojiPageView.addOnScrollListener(new TopAndBottomShadowHelper(requireView().findViewById(R.id.react_with_any_emoji_top_shadow),
tabBar.findViewById(R.id.react_with_any_emoji_bottom_shadow)));
@@ -23,6 +23,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.Fragment;
import com.bumptech.glide.load.DataSource;
@@ -66,6 +67,7 @@ import org.thoughtcrime.securesms.scribbles.stickers.FeatureSticker;
import org.thoughtcrime.securesms.scribbles.stickers.TappableRenderer;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentUtil;
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
@@ -234,12 +236,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
Mode mode = Mode.getByCode(requireArguments().getString(KEY_MODE));
if (mode == Mode.AVATAR_CAPTURE || mode == Mode.AVATAR_EDIT) {
view.setPadding(
0,
ViewUtil.getStatusBarHeight(view),
0,
ViewUtil.getNavigationBarHeight(view)
);
SystemWindowInsetsSetter.attach(view, getViewLifecycleOwner(), WindowInsetsCompat.Type.systemBars());
}
imageEditorHud = view.findViewById(R.id.scribble_hud);
@@ -8,6 +8,7 @@ import android.view.Window;
import android.view.WindowManager;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.Insets;
import androidx.core.view.DisplayCutoutCompat;
import androidx.core.view.ViewCompat;
@@ -92,7 +93,7 @@ public final class FullscreenHelper {
public void showAndHideWithSystemUI(@NonNull Window window, @NonNull View... views) {
ViewCompat.setOnApplyWindowInsetsListener(window.getDecorView(), (view, insets) -> {
boolean hide = !insets.isVisible(WindowInsetsCompat.Type.systemBars());
boolean hide = !areBarsVisible(insets);
for (View target : views) {
if (target == null) {
@@ -114,7 +115,7 @@ public final class FullscreenHelper {
.start();
}
return insets;
return ViewCompat.onApplyWindowInsets(view, insets);
});
}
@@ -127,8 +128,22 @@ public final class FullscreenHelper {
}
public boolean isSystemUiVisible() {
WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(activity.getWindow().getDecorView());
return insets == null || insets.isVisible(WindowInsetsCompat.Type.systemBars());
return areBarsVisible(ViewCompat.getRootWindowInsets(activity.getWindow().getDecorView()));
}
/**
* Whether the bars that {@link #showSystemUI()} / {@link #hideSystemUI()} control are currently on screen.
* <p>
* Checks the two bars individually rather than {@link WindowInsetsCompat.Type#systemBars()}, which also
* covers the caption bar: {@code isVisible} requires every requested type to be visible, and a phone window
* has no caption bar source, so the aggregate answer is always "hidden".
*/
private static boolean areBarsVisible(@Nullable WindowInsetsCompat insets) {
if (insets == null) {
return true;
}
return insets.isVisible(WindowInsetsCompat.Type.statusBars()) || insets.isVisible(WindowInsetsCompat.Type.navigationBars());
}
public void hideSystemUI() {
@@ -22,10 +22,14 @@ object SystemWindowInsetsSetter {
}
/**
* Updates the view whenever a layout occurs to properly account for the system bar insets, added
* on top of the view's original padding ([ApplyMode.PADDING]) or margin ([ApplyMode.MARGIN]).
* This is safe to call repeatedly because it only triggers an extra layout pass IF the applied
* values actually changed.
* Accounts for the system bar insets by adding them on top of the view's original padding
* ([ApplyMode.PADDING]) or margin ([ApplyMode.MARGIN]).
*
* Applied from two places. Primarily from the inset dispatch, which runs before measure and layout, so the
* first frame is already inset instead of visibly shifting a frame later. That dispatch doesn't reach every
* view though (an ancestor may consume the insets first), so each layout re-applies as a fallback, posted
* because a layout-time `requestLayout()` is dropped by the framework. Both paths are safe to run
* repeatedly: they only trigger another layout if the values actually changed.
*/
@JvmStatic
@JvmOverloads
@@ -42,30 +46,41 @@ object SystemWindowInsetsSetter {
Insets.of(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)
}
val listener = view.doOnEachLayout {
val applyInsets = {
val insets = resolveInsets(view, insetType)
val left = base.left + insets.left
val top = base.top + insets.top
val right = base.right + insets.right
val bottom = base.bottom + insets.bottom
view.post {
when (applyMode) {
ApplyMode.PADDING -> view.setPadding(left, top, right, bottom)
ApplyMode.MARGIN -> {
val params = view.layoutParams as? ViewGroup.MarginLayoutParams ?: return@post
if (params.leftMargin != left || params.topMargin != top || params.rightMargin != right || params.bottomMargin != bottom) {
params.setMargins(left, top, right, bottom)
view.layoutParams = params
}
when (applyMode) {
ApplyMode.PADDING -> view.setPadding(left, top, right, bottom)
ApplyMode.MARGIN -> {
val params = view.layoutParams as? ViewGroup.MarginLayoutParams
if (params != null && (params.leftMargin != left || params.topMargin != top || params.rightMargin != right || params.bottomMargin != bottom)) {
params.setMargins(left, top, right, bottom)
view.layoutParams = params
}
}
}
}
ViewCompat.setOnApplyWindowInsetsListener(view) { target, windowInsets ->
// Let the view dispatch on down to its children first, then apply ours on top.
val result = ViewCompat.onApplyWindowInsets(target, windowInsets)
applyInsets()
result
}
val listener = view.doOnEachLayout {
view.post { applyInsets() }
}
val lifecycleObserver = object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
view.removeOnLayoutChangeListener(listener)
ViewCompat.setOnApplyWindowInsetsListener(view, null)
}
}
@@ -16,13 +16,15 @@
android:background="@drawable/bottom_toolbar_shadow" />
<LinearLayout
android:id="@+id/emoji_categories_row"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@color/react_with_any_background"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:minHeight="?actionBarSize"
android:orientation="horizontal">
<FrameLayout
+1 -1
View File
@@ -3394,7 +3394,7 @@
<!-- Notification channel name for showing call status information (like connection, ongoing, etc.) Not ringing. -->
<string name="NotificationChannel_call_status">Estado de la llamada</string>
<!-- Notification channel name for occasional alerts to the user. Will appear in the system notification settings as the title of this notification channel. -->
<string name="NotificationChannel_critical_app_alerts">Alertas críticas de apps</string>
<string name="NotificationChannel_critical_app_alerts">Alertas críticas de la app</string>
<!-- Notification channel name for other notifications related to messages. Will appear in the system notification settings as the title of this notification channel. -->
<string name="NotificationChannel_additional_message_notifications">Otras notificaciones de mensajes</string>
<!-- Notification channel name for notifications sent when a device has been linked -->
+2 -2
View File
@@ -8774,9 +8774,9 @@
<!-- Subtitle for row for canceled backup -->
<string name="BackupsSettingsFragment__subscription_canceled">Abonnement résilié</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Signal sauvegarde automatiquement vos données avec son service de stockage sécurisé et chiffré de bout en bout.</string>
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Sauvegardes automatiques chiffrées de bout en bout grâce au service de stockage sécurisé de Signal.</string>
<!-- Subtitle shown on a linked device when backups are off, directing the user to set them up on their phone -->
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Signal sauvegarde automatiquement vos données avec son service de stockage sécurisé et chiffré de bout en bout. Ouvrez Signal sur votre téléphone pour vous lancer.</string>
<string name="BackupsSettingsFragment__automatic_backups_get_started_on_your_phone">Sauvegardes automatiques chiffrées de bout en bout grâce au service de stockage sécurisé de Signal. Ouvrez Signal sur votre téléphone pour vous lancer.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"Abonnement introuvable sur cet appareil."</string>
<!-- Action button label to set up backups -->
+57 -57
View File
@@ -841,9 +841,9 @@
<string name="ConversationTypingView__plus_d">+%1$d</string>
<!-- Title for a reminder bottom sheet to users who have re-registered that they need to go back to re-link their devices. -->
<string name="RelinkDevicesReminderFragment__relink_your_devices">기기 다시 연</string>
<string name="RelinkDevicesReminderFragment__relink_your_devices">기기 다시 연동하세요ㄴ=</string>
<!-- Description for a reminder bottom sheet to users who have re-registered that they need to go back to re-link their devices. -->
<string name="RelinkDevicesReminderFragment__the_devices_you_added_were_unlinked">기기 등록을 해제하여 기기 연이 해제됐습니다. 설정으로 가서 기기를 다시 연하세요.</string>
<string name="RelinkDevicesReminderFragment__the_devices_you_added_were_unlinked">기기 등록을 해제하여 기기 연이 해제됐습니다. 설정으로 가서 기기를 다시 연하세요.</string>
<!-- Button label for the re-link devices bottom sheet reminder to navigate to the Devices page in the settings. -->
<string name="RelinkDevicesReminderFragment__open_settings">설정 열기</string>
<!-- Button label for the re-link devices bottom sheet reminder to dismiss the pop up. -->
@@ -1052,7 +1052,7 @@
<!-- Bottom sheet description explaining that future messages on linked devices will be in sync with your phone but previous messages will not appear -->
<string name="LinkDeviceFragment__signal_messages_are_synchronized">휴대폰의 Signal과 연결하면 Signal 메시지가 동기화됩니다. 이전 메시지 내역은 표시되지 않습니다.</string>
<!-- Bottom sheet description explaining that for non-desktop/iPad devices, they should go to %s to download Signal where %s is Signal\'s website -->
<string name="LinkDeviceFragment__on_other_device_visit_signal">하려는 기기에서 %1$s 링크를 방문하여 Signal을 설치하세요.</string>
<string name="LinkDeviceFragment__on_other_device_visit_signal">하려는 기기에서 %1$s 링크를 방문하여 Signal을 설치하세요.</string>
<!-- Removed by excludeNonTranslatables <string name="LinkDeviceFragment__signal_download_url" translatable="false">signal.org/download</string> -->
<!-- Header title listing out current linked devices -->
<string name="LinkDeviceFragment__my_linked_devices">내 연결된 기기</string>
@@ -1061,7 +1061,7 @@
<!-- Toast message indicating a device has been unlinked, where %s is the name of the device -->
<string name="LinkDeviceFragment__s_unlinked">\'%1$s\'의 연결을 해제했습니다.</string>
<!-- Toast message indicating a device has been successfully linked, where %s is the name of the device -->
<string name="LinkDeviceFragment__s_linked">\'%1$s\' 기기를 연했습니다.</string>
<string name="LinkDeviceFragment__s_linked">\'%1$s\' 기기를 연했습니다.</string>
<!-- Progress dialog message indicating that a device is currently being linked with an account -->
<string name="LinkDeviceFragment__linking_device">기기를 연결하는 중…</string>
<!-- Toast message shown after a device has been linked -->
@@ -1098,9 +1098,9 @@
<!-- Title of a dialog letting the user know that syncing messages to their linked device failed -->
<string name="LinkDeviceFragment__sync_failure_title">메시지 동기화 실패</string>
<!-- Body of a dialog letting the user know that syncing messages to their linked device failed -->
<string name="LinkDeviceFragment__sync_failure_body">결된 기기로 메시지 기록을 가져오지 못했습니다. 다시 연해서 메시지 기록을 가져오거나, 가져오지 않고 그대로 시작할 수 있습니다.</string>
<string name="LinkDeviceFragment__sync_failure_body"> 기기로 메시지 기록을 가져오지 못했습니다. 다시 연해서 메시지 기록을 가져오거나, 가져오지 않고 그대로 시작할 수 있습니다.</string>
<!-- Body of a dialog letting the user know that syncing messages to their linked device failed but the device was still able to be linked -->
<string name="LinkDeviceFragment__sync_failure_body_unretryable">기기 연은 완료되었으나, 메시지 기록을 가져오지 못했습니다.</string>
<string name="LinkDeviceFragment__sync_failure_body_unretryable">기기 연은 완료되었으나, 메시지 기록을 가져오지 못했습니다.</string>
<!-- Text button in a dialog that, when pressed, will redirect to the Signal support page -->
<string name="LinkDeviceFragment__learn_more">자세히 알아보기</string>
<!-- Removed by excludeNonTranslatables <string name="LinkDeviceFragment__learn_more_url" translatable="false">https://support.signal.org/hc/articles/360007320551</string> -->
@@ -1125,7 +1125,7 @@
<!-- Dialog title shown when you don\'t have enough storage space -->
<string name="LinkDeviceFragment__not_enough_storage_space">저장 공간 부족</string>
<!-- Dialog message body explaining that you need to free storage space or transfer without sending over message -->
<string name="LinkDeviceFragment__you_dont_have_enough">기기에 메시지를 가져올 저장 공간이 부족합니다. 여유 공간을 확보한 뒤 다시 시도하거나, 메시지 이전 없이 기기를 연해 보세요.</string>
<string name="LinkDeviceFragment__you_dont_have_enough">기기에 메시지를 가져올 저장 공간이 부족합니다. 여유 공간을 확보한 뒤 다시 시도하거나, 메시지 이전 없이 기기를 연해 보세요.</string>
<!-- EditDeviceNameFragment -->
<!-- App bar title when editing the name of a device -->
@@ -1155,7 +1155,7 @@
<!-- Bottom sheet description telling users to complete the linking process -->
<string name="AddLinkDeviceFragment__finish_linking_signal">다른 기기에서 Signal 연결을 완료하세요.</string>
<!-- Title of dialog when the QR code being scanned is invalid -->
<string name="AddLinkDeviceFragment__linking_device_failed">기기 연 실패</string>
<string name="AddLinkDeviceFragment__linking_device_failed">기기 연 실패</string>
<!-- Text shown in a dialog when the QR code being scanned is invalid -->
<string name="AddLinkDeviceFragment__this_qr_code_not_valid">QR 코드가 올바르지 않습니다. 스캔하고자 하는 기기에 있는 QR 코드를 읽었는지 다시 한번 확인하세요.</string>
<!-- Button in dialog to retry linking a device with a qr code -->
@@ -1171,26 +1171,26 @@
<!-- Option in bottom sheet to not transfer message history -->
<string name="LinkDeviceSyncBottomSheet_dont_transfer">이전 안 함</string>
<!-- Description in bottom sheet of what not transferring history will do -->
<string name="LinkDeviceSyncBottomSheet_no_old_messages">기존 메시지 또는 미디어를 연결된 기기로 이전하지 않습니다</string>
<string name="LinkDeviceSyncBottomSheet_no_old_messages">기존 메시지 또는 미디어를 연 기기로 이전하지 않습니다</string>
<!-- Title of notification telling users that a device was linked to their account -->
<string name="NewLinkedDeviceNotification__you_linked_new_device">새 기기를 연</string>
<string name="NewLinkedDeviceNotification__you_linked_new_device">새 기기를 연</string>
<!-- Message body of notification telling users that a device was linked to their account. %1$s is the time it was linked -->
<string name="NewLinkedDeviceNotification__a_new_device_was_linked">%1$s 새 기기를 계정에 연했습니다. 탭하여 확인하세요.</string>
<string name="NewLinkedDeviceNotification__a_new_device_was_linked">%1$s 새 기기를 계정에 연했습니다. 탭하여 확인하세요.</string>
<!-- DeviceListActivity -->
<string name="DeviceListActivity_unlink_s">\'%1$s\' 기기결을 해제할까요?</string>
<string name="DeviceListActivity_by_unlinking_this_device_it_will_no_longer_be_able_to_send_or_receive">기기를 연 해제하면 메시지를 주고받을 수 없습니다.</string>
<string name="DeviceListActivity_unlink_s">\'%1$s\' 기기 해제할까요?</string>
<string name="DeviceListActivity_by_unlinking_this_device_it_will_no_longer_be_able_to_send_or_receive">기기를 연 해제하면 메시지를 주고받을 수 없습니다.</string>
<string name="DeviceListActivity_network_connection_failed">네트워크 연결 실패</string>
<!-- Button label on an alert dialog. The dialog informs the user they have network issues. If pressed, we will retry the network request. -->
<string name="DeviceListActivity_try_again">다시 시도해 주세요</string>
<string name="DeviceListActivity_unlinking_device">기기 연 해제 중…</string>
<string name="DeviceListActivity_unlinking_device_no_ellipsis">기기 연 해제 중</string>
<string name="DeviceListActivity_unlinking_device">기기 연 해제 중…</string>
<string name="DeviceListActivity_unlinking_device_no_ellipsis">기기 연 해제 중</string>
<string name="DeviceListActivity_network_failed">네트워크 연결 실패</string>
<!-- DeviceListItem -->
<string name="DeviceListItem_unnamed_device">이름 없는 기기</string>
<string name="DeviceListItem_linked_s">%1$s 연</string>
<string name="DeviceListItem_linked_s">%1$s 연</string>
<string name="DeviceListItem_last_active_s">%1$s 마지막으로 사용됨</string>
<string name="DeviceListItem_today">오늘</string>
@@ -1784,7 +1784,7 @@
<string name="Megaphones_add_a_profile_photo">프로필 사진 추가</string>
<!-- Message body of megaphone telling users that a device was linked to their account. %1$s is the time it was linked -->
<string name="NewLinkedDeviceMegaphone__a_new_device_was_linked">%1$s 새 기기를 계정에 연했습니다.</string>
<string name="NewLinkedDeviceMegaphone__a_new_device_was_linked">%1$s 새 기기를 계정에 연했습니다.</string>
<!-- Button shown on megaphone that will redirect to the linked devices screen -->
<string name="NewLinkedDeviceMegaphone__view_device">기기 보기</string>
<!-- Button shown on megaphone to acknowledge and dismiss the megaphone -->
@@ -2252,24 +2252,24 @@
<string name="PassphraseChangeActivity_enter_new_passphrase_exclamation">새 암호 문구를 입력하세요.</string>
<!-- DeviceProvisioningActivity -->
<string name="DeviceProvisioningActivity_link_this_device">기기를 연할까요?</string>
<string name="DeviceProvisioningActivity_link_this_device">기기를 연할까요?</string>
<string name="DeviceProvisioningActivity_continue">계속</string>
<string name="DeviceProvisioningActivity_content_intro">다음 수행 가능:</string>
<string name="DeviceProvisioningActivity_content_bullets">
• 모든 메시지 읽기 \n• 내 이름으로 메시지 보내기
</string>
<string name="DeviceProvisioningActivity_content_progress_title">기기 연</string>
<string name="DeviceProvisioningActivity_content_progress_content">새 기기 연 중…</string>
<string name="DeviceProvisioningActivity_content_progress_title">기기 연동 중</string>
<string name="DeviceProvisioningActivity_content_progress_content">새 기기 연 중…</string>
<string name="DeviceProvisioningActivity_content_progress_success">기기가 승인되었습니다.</string>
<string name="DeviceProvisioningActivity_content_progress_no_device">기기를 찾을 수 없습니다.</string>
<string name="DeviceProvisioningActivity_content_progress_network_error">네트워크 오류가 발생했습니다.</string>
<string name="DeviceProvisioningActivity_content_progress_key_error">잘못된 QR 코드입니다.</string>
<!-- Toast message shown when a user has too many linked devices and needs to remove one before linking another -->
<string name="DeviceProvisioningActivity_sorry_you_have_too_many_devices_linked_already">현재 연된 기기 수가 한도를 초과했습니다. 일부 기기를 해제한 후 다시 시도해 주세요.</string>
<string name="DeviceActivity_sorry_this_is_not_a_valid_device_link_qr_code">올바른 기기 연 QR 코드가 아닙니다.</string>
<string name="DeviceProvisioningActivity_link_a_signal_device">Signal 기기를 연할까요?</string>
<string name="DeviceProvisioningActivity_to_link_a_desktop_or_ipad_to_this_signal_account">이 Signal 계정에 데스크톱이나 iPad를 연하려면 연결된 기기로 이동하여 \'새 기기 연\'을 탭한 다음 QR 코드를 다시 스캔하세요. Signal에서 직접 제공한 QR 코드만 스캔하세요.</string>
<string name="DeviceProvisioningActivity_sorry_you_have_too_many_devices_linked_already">현재 연된 기기 수가 한도를 초과했습니다. 일부 기기를 해제한 후 다시 시도해 주세요.</string>
<string name="DeviceActivity_sorry_this_is_not_a_valid_device_link_qr_code">올바른 기기 연 QR 코드가 아닙니다.</string>
<string name="DeviceProvisioningActivity_link_a_signal_device">Signal 기기를 연할까요?</string>
<string name="DeviceProvisioningActivity_to_link_a_desktop_or_ipad_to_this_signal_account">이 Signal 계정에 데스크톱이나 iPad를 연하려면 \'연동 기기\'로 이동하여 \'새 기기 연\'을 탭한 다음 QR 코드를 다시 스캔하세요. Signal에서 직접 제공한 QR 코드만 스캔하세요.</string>
<string name="DeviceActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code">Signal에서 QR 코드를 읽으려면 카메라 권한이 필요하지만, 현재 권한이 차단되어 있습니다. 앱 설정 메뉴에서 \'권한\'을 선택한 후 \'카메라\' 항목을 허용해 주세요.</string>
<string name="DeviceActivity_unable_to_scan_a_qr_code_without_the_camera_permission">카메라 권한이 없어 QR 코드를 스캔할 수 없습니다</string>
@@ -2631,7 +2631,7 @@
<!-- AboutSheet -->
<!-- Displayed in a sheet row and allows user to open signal connection explanation on tap -->
<string name="AboutSheet__signal_connection">Signal 커넥션</string>
<string name="AboutSheet__signal_connection">Signal 친구</string>
<!-- Displayed in a sheet row describing that the user has marked this contact as \'verified\' from within the app -->
<string name="AboutSheet__verified">인증 완료</string>
<!-- Displayed in a sheet row that tells users that profile names are not verified -->
@@ -3295,7 +3295,7 @@
<!-- Notification channel name for other notifications related to messages. Will appear in the system notification settings as the title of this notification channel. -->
<string name="NotificationChannel_additional_message_notifications">추가 메시지 알림</string>
<!-- Notification channel name for notifications sent when a device has been linked -->
<string name="NotificationChannel_new_linked_device">새 연된 기기</string>
<string name="NotificationChannel_new_linked_device">된 기기</string>
<!-- QuickResponseService -->
<string name="QuickResponseService_quick_response_unavailable_when_Signal_is_locked">Signal가 잠겨 있는 경우 빠른 답장을 사용할 수 없어요.</string>
@@ -3427,9 +3427,9 @@
<!-- Message shown in permission dialog when attempting to answer a video call without camera or microphone permissions already granted. -->
<string name="WebRtcCallActivity_to_answer_the_call_give_signal_access_to_your_microphone_and_camera">영상 통화를 수락하려면 Signal에서 마이크와 카메라를 사용하도록 허용하세요.</string>
<string name="WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls">Signal에서 전화 기능을 사용하려면 마이크와 카메라 권한이 필요하지만, 현재 권한이 차단되어 있습니다. 앱 설정 메뉴에서 \'권한\'을 선택한 후 \'마이크\'와 \'카메라\' 항목을 허용해 주세요.</string>
<string name="WebRtcCallActivity__answered_on_a_linked_device">결된 기기에서 응답했습니다.</string>
<string name="WebRtcCallActivity__declined_on_a_linked_device">결된 기기에서 거부했습니다.</string>
<string name="WebRtcCallActivity__busy_on_a_linked_device">결된 기기에서 이미 통화 중입니다.</string>
<string name="WebRtcCallActivity__answered_on_a_linked_device"> 기기에서 응답했습니다.</string>
<string name="WebRtcCallActivity__declined_on_a_linked_device"> 기기에서 거부했습니다.</string>
<string name="WebRtcCallActivity__busy_on_a_linked_device"> 기기에서 이미 통화 중입니다.</string>
<!-- Tooltip message shown first time user is in a video call after switch camera button moved -->
<string name="WebRtcCallActivity__flip_camera_tooltip">카메라 전환 기능이 여기로 이동했어요. 영상 화면을 탭하여 사용해 보세요.</string>
@@ -3699,14 +3699,14 @@
<string name="country_selection_fragment__no_matching_countries">일치하는 국가 없음</string>
<!-- device_add_fragment -->
<string name="device_add_fragment__scan_the_qr_code_displayed_on_the_device_to_link">기기에 표시된 QR 코드를 스캔하여 기기를 연하세요.</string>
<string name="device_add_fragment__scan_the_qr_code_displayed_on_the_device_to_link">기기에 표시된 QR 코드를 스캔하여 기기를 연하세요.</string>
<!-- device_link_fragment -->
<string name="device_link_fragment__link_device">기기 연</string>
<string name="device_link_fragment__link_device">기기 연</string>
<!-- device_list_fragment -->
<string name="device_list_fragment__no_devices_linked">결된 기기 없음</string>
<string name="device_list_fragment__link_new_device">새 기기 연</string>
<string name="device_list_fragment__no_devices_linked"> 기기 없음</string>
<string name="device_list_fragment__link_new_device">새 기기 연</string>
<!-- expiration -->
<string name="expiration_off">꺼짐</string>
@@ -3943,7 +3943,7 @@
<string name="AndroidManifest__verify_safety_number">안전 번호 인증</string>
<string name="AndroidManifest__media_preview">미디어 미리보기</string>
<string name="AndroidManifest__message_details">메시지 세부 정보</string>
<string name="AndroidManifest__linked_devices">결된 기기</string>
<string name="AndroidManifest__linked_devices"> 기기</string>
<string name="AndroidManifest__invite_friends">친구 초대</string>
<string name="AndroidManifest_archived_conversations">보관된 대화</string>
@@ -4132,7 +4132,7 @@
<string name="preferences__conversation_length_limit">채팅 내 최대 메시지 수</string>
<string name="preferences__keep_messages">메시지 보관</string>
<string name="preferences__clear_message_history">메시지 기록 삭제</string>
<string name="preferences__linked_devices">결된 기기</string>
<string name="preferences__linked_devices"> 기기</string>
<string name="preferences__light_theme">밝게</string>
<string name="preferences__dark_theme">어둡게</string>
<string name="preferences__appearance">모양</string>
@@ -4202,7 +4202,7 @@
</plurals>
<string name="preferences_storage__this_will_delete_all_message_history_and_media_from_your_device">이 기기의 모든 메시지 기록과 미디어를 영구 삭제합니다.</string>
<!-- Dialog message warning about delete chat message history will delete everything everywhere including linked devices -->
<string name="preferences_storage__this_will_delete_all_message_history_and_media_from_your_device_linked_device">이 기기와 모든 연결된 기기에서 영구적으로 모든 메시지 기록과 미디어를 삭제합니다.</string>
<string name="preferences_storage__this_will_delete_all_message_history_and_media_from_your_device_linked_device">이 기기와 모든 연 기기에서 영구적으로 모든 메시지 기록과 미디어를 삭제합니다.</string>
<string name="preferences_storage__are_you_sure_you_want_to_delete_all_message_history">모든 메시지 기록을 정말 삭제하시겠어요?</string>
<string name="preferences_storage__all_message_history_will_be_permanently_removed_this_action_cannot_be_undone">모든 메시지 기록을 영구적으로 제거합니다. 이 작업은 다시 되돌릴 수 없습니다.</string>
<!-- Secondary warning dialog message about deleting all chat message history from everything everywhere including linked devices -->
@@ -5000,7 +5000,7 @@
<!-- LinkedDeviceAccountSettingsFragment -->
<!-- Title of an informational card shown on the account settings screen of a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">연결된 기기입니다</string>
<string name="LinkedDeviceAccountSettingsFragment__this_is_a_linked_device">이 기기는 연동 기기입니다</string>
<!-- Body of an informational card explaining that account settings must be managed from the primary device -->
<string name="LinkedDeviceAccountSettingsFragment__to_manage_your_account_settings">계정 설정을 관리하려면 기본 사용 기기에서 Signal을 열어 주세요.</string>
<!-- Title of a settings row that deletes all Signal data from this linked device -->
@@ -5010,17 +5010,17 @@
<!-- Title of the confirmation dialog shown when deleting app data from a linked device -->
<string name="LinkedDeviceAccountSettingsFragment__delete_app_data_question">앱 데이터를 삭제할까요?</string>
<!-- Message of the confirmation dialog explaining that only this device\'s data is deleted, not the account -->
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">이 기기에 저장된 Signal 앱의 모든 데이터와 메시지가 삭제됩니다. 기본 사용 기기 또는 다른 연결된 기기에 저장된 Signal 계정과 데이터는 삭제되지 않습니다. 이 절차가 완료되면 자동으로 앱이 종료됩니다.</string>
<string name="LinkedDeviceAccountSettingsFragment__this_will_delete_all_data_and_messages">이 기기에 저장된 Signal 앱의 모든 데이터와 메시지가 삭제됩니다. 기본 사용 기기 또는 다른 연 기기에 저장된 Signal 계정과 데이터는 삭제되지 않습니다. 이 절차가 완료되면 자동으로 앱이 종료됩니다.</string>
<!-- LinkedDeviceAccountLearnMoreBottomSheet -->
<!-- Emphasized lead-in phrase shown in bold at the start of the learn more sheet body. Must appear verbatim within LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices">결된 기기</string>
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices"> 기기</string>
<!-- Body text of the linked devices learn more sheet. The leading \"Linked Devices\" is shown in bold. -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access">결된 기기 기능을 사용하면 다른 추가 기기에서도 Signal 계정과 메시지를 안전하게 이용할 수 있습니다.</string>
<string name="LinkedDeviceAccountLearnMoreBottomSheet__linked_devices_let_you_access"> 기기 기능을 사용하면 다른 추가 기기에서도 Signal 계정과 메시지를 안전하게 이용할 수 있습니다.</string>
<!-- Bullet point describing what devices can be linked -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">데스크톱, 태블릿 또는 추가 휴대폰을 연할 수 있습니다.</string>
<string name="LinkedDeviceAccountLearnMoreBottomSheet__you_can_link_a_desktop">데스크톱, 태블릿 또는 추가 휴대폰을 연할 수 있습니다.</string>
<!-- Bullet point describing that the primary device manages linked devices -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages">결된 기기는 기본 기기에서 관리하며, 최대 5대까지 연할 수 있습니다.</string>
<string name="LinkedDeviceAccountLearnMoreBottomSheet__your_primary_device_manages"> 기기는 기본 기기에서 관리하며, 최대 5대까지 연할 수 있습니다.</string>
<!-- Bullet point describing that some settings are only available on the primary device -->
<string name="LinkedDeviceAccountLearnMoreBottomSheet__some_account_settings">계정 설정 및 백업을 포함한 일부 계정 설정은 기본 기기에서만 관리할 수 있습니다.</string>
@@ -5414,7 +5414,7 @@
<!-- DeactivateWalletFragment -->
<string name="DeactivateWalletFragment__deactivate_wallet">지갑 비활성화</string>
<string name="DeactivateWalletFragment__your_balance">잔액</string>
<string name="DeactivateWalletFragment__its_recommended_that_you">결제를 비활성화하기 전에 다른 지갑 주소로 자금을 이체하는 것이 좋습니다. 지금 이체하지 않더라도, 나중에 결제 기능을 다시 활성화하면 Signal에 연된 지갑 내 잔액을 그대로 이용하실 수 있습니다.</string>
<string name="DeactivateWalletFragment__its_recommended_that_you">결제를 비활성화하기 전에 다른 지갑 주소로 자금을 이체하는 것이 좋습니다. 지금 이체하지 않더라도, 나중에 결제 기능을 다시 활성화하면 Signal에 연된 지갑 내 잔액을 그대로 이용하실 수 있습니다.</string>
<string name="DeactivateWalletFragment__transfer_remaining_balance">잔액 이체</string>
<string name="DeactivateWalletFragment__deactivate_without_transferring">이전하지 않고 비활성화</string>
<string name="DeactivateWalletFragment__deactivate">비활성화</string>
@@ -6320,7 +6320,7 @@
<string name="MediaCaptureFragment_username_dialog_go_to_chat_button">대화로 이동</string>
<!-- The title of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="MediaCaptureFragment_device_link_dialog_title">기기를 연할까요?</string>
<string name="MediaCaptureFragment_device_link_dialog_title">기기를 연할까요?</string>
<!-- The body of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="MediaCaptureFragment_it_looks_like_youre_trying">Signal 기기를 연결하려는 것 같네요. 계속을 탭한 다음 \'새 기기 연결\'을 탭하고 QR 코드를 다시 스캔하세요.</string>
<!-- The label of a dialog asking the user if they would like to continue to the linked device settings screen. -->
@@ -6992,7 +6992,7 @@
<string name="MyStorySettingsFragment__who_can_view_this_story">이 스토리를 볼 수 있는 사람</string>
<!-- Clickable option for selecting people to hide your story from -->
<!-- Privacy setting title for sending stories to all your signal connections -->
<string name="MyStorySettingsFragment__all_signal_connections">모든 Signal 커넥션</string>
<string name="MyStorySettingsFragment__all_signal_connections">모든 Signal 친구</string>
<!-- Privacy setting description for sending stories to all your signal connections -->
<!-- Privacy setting title for sending stories to all except the specified connections -->
<string name="MyStorySettingsFragment__all_except">일부를 제외한 모든 사용자</string>
@@ -7019,9 +7019,9 @@
<!-- Summary for switchable option allowing replies and reactions on your story -->
<string name="MyStorySettingsFragment__let_people_who_can_view_your_story_react_and_reply">내 스토리를 볼 수 있는 사용자의 공감 및 답장 허용</string>
<!-- Signal connections bolded text in the Signal Connections sheet -->
<string name="SignalConnectionsBottomSheet___signal_connections">Signal 커넥션</string>
<string name="SignalConnectionsBottomSheet___signal_connections">Signal 친구</string>
<!-- Displayed at the top of the signal connections sheet. Please remember to insert strong tag as required. -->
<string name="SignalConnectionsBottomSheet__signal_connections_are_people">Signal 커넥션은 다음과 같은 방법을 통해 신뢰하는 사용자로 선택한 사람들입니다.</string>
<string name="SignalConnectionsBottomSheet__signal_connections_are_people">Signal 친구는 다음과 같은 방법을 통해 신뢰하는 사용자로 지정된 사람들입니다.</string>
<!-- Signal connections sheet bullet point 1 -->
<string name="SignalConnectionsBottomSheet__starting_a_conversation">대화 시작</string>
<!-- Signal connections sheet bullet point 2 -->
@@ -7029,7 +7029,7 @@
<!-- Signal connections sheet bullet point 3 -->
<string name="SignalConnectionsBottomSheet__having_them_in_your_system_contacts">내 휴대폰 연락처에 저장</string>
<!-- Note at the bottom of the Signal connections sheet -->
<string name="SignalConnectionsBottomSheet__your_connections_can_see_your_name">"커넥션은 나의 이름과 사진을 볼 수 있고 스토리를 따로 숨기지 않는 한 \'내 스토리\'의 게시물을 볼 수 있습니다."</string>
<string name="SignalConnectionsBottomSheet__your_connections_can_see_your_name">"Signal 친구는 나의 이름과 사진을 볼 수 있고 스토리를 따로 숨기지 않는 한 \'내 스토리\'의 게시물을 볼 수 있습니다."</string>
<!-- Clickable option to add a viewer to a custom story -->
<string name="PrivateStorySettingsFragment__add_viewer">볼 수 있는 사람 추가</string>
<!-- Clickable option to delete a custom story -->
@@ -7301,27 +7301,27 @@
<!-- Title of safety number changes bottom sheet when showing individual records -->
<string name="SafetyNumberBottomSheetFragment__safety_number_changes">안전 번호 변경 기록</string>
<!-- Message of safety number changes bottom sheet when showing individual records -->
<string name="SafetyNumberBottomSheetFragment__the_following_people">다음 사용자는 Signal을 다시 설치했거나 기기를 변경했을 수 있습니다. 받는 사람을 탭하여 새 안전 번호를 확인하세요. 이 작업은 선택 사항입니다.</string>
<string name="SafetyNumberBottomSheetFragment__the_following_people">다음 사용자는 Signal을 설치했거나 기기를 변경했을 수 있습니다. 새 안전 번호를 확인하려면 해당 사용자를 탭하세요. (선택 사항)</string>
<!-- Title of safety number changes bottom sheet when not showing individual records -->
<string name="SafetyNumberBottomSheetFragment__safety_number_checkup">안전 번호 확인</string>
<!-- Title of safety number changes bottom sheet when not showing individual records and user has seen review screen -->
<string name="SafetyNumberBottomSheetFragment__safety_number_checkup_complete">안전 번호 확인 완료</string>
<!-- Message of safety number changes bottom sheet when not showing individual records and user has seen review screen -->
<string name="SafetyNumberBottomSheetFragment__all_connections_have_been_reviewed">모든 커넥션을 검토했습니다. 보내기를 탭하여 계속하세요.</string>
<string name="SafetyNumberBottomSheetFragment__all_connections_have_been_reviewed">모든 친구를 검토했습니다. 전송을 탭하여 계속하세요.</string>
<!-- Message of safety number changes bottom sheet when not showing individual records -->
<plurals name="SafetyNumberBottomSheetFragment__you_have_d_connections_plural">
<item quantity="other">%1$d명의 사용자가 Signal을 재설치했거나 기기를 변경했을 수 있습니다. 안전 번호를 검토하거나 전송을 계속하세요.</item>
<item quantity="other">%1$d명의 Signal 친구가 앱을 재설치했거나 기기를 변경했을 수 있습니다. 안전 번호를 다시 확인하거나 이대로 메시지를 전송할 수 있습니다.</item>
</plurals>
<!-- Menu action to launch safety number verification screen -->
<string name="SafetyNumberBottomSheetFragment__verify_safety_number">안전 번호 인증</string>
<!-- Menu action to remove user from story -->
<string name="SafetyNumberBottomSheetFragment__remove_from_story">스토리에서 제거</string>
<!-- Action button at bottom of SafetyNumberBottomSheetFragment to send anyway -->
<string name="SafetyNumberBottomSheetFragment__send_anyway">냥 보내</string>
<string name="SafetyNumberBottomSheetFragment__send_anyway">래도 전송하</string>
<!-- Action button at bottom of SafetyNumberBottomSheetFragment to review connections -->
<string name="SafetyNumberBottomSheetFragment__review_connections">커넥션 검토</string>
<string name="SafetyNumberBottomSheetFragment__review_connections">친구 검토하기</string>
<!-- Empty state copy for SafetyNumberBottomSheetFragment -->
<string name="SafetyNumberBottomSheetFragment__no_more_recipients_to_show">표시할 받는 사람이 없습니다.</string>
<string name="SafetyNumberBottomSheetFragment__no_more_recipients_to_show">더 이상 표시할 수신인이 없습니다</string>
<!-- Done button on safety number review fragment -->
<string name="SafetyNumberReviewConnectionsFragment__done">확인</string>
<!-- Title of safety number review fragment -->
@@ -7342,7 +7342,7 @@
<!-- Subtitle of initial My Story settings configuration shown when sending to My Story for the first time -->
<string name="ChooseInitialMyStoryMembershipFragment__choose_who_can_see_posts_to_my_story_you_can_always_make_changes_in_settings">내 스토리 게시물을 볼 수 있는 사람을 선택하세요. 설정에서 언제든지 변경할 수 있습니다.</string>
<!-- All connections option for initial My Story settings configuration shown when sending to My Story for the first time -->
<string name="ChooseInitialMyStoryMembershipFragment__all_signal_connections">모든 Signal 커넥션</string>
<string name="ChooseInitialMyStoryMembershipFragment__all_signal_connections">모든 Signal 친구</string>
<!-- All connections except option for initial My Story settings configuration shown when sending to My Story for the first time -->
<string name="ChooseInitialMyStoryMembershipFragment__all_except">일부를 제외한 모든 사용자</string>
<!-- Only with selected connections option for initial My Story settings configuration shown when sending to My Story for the first time -->
@@ -8195,10 +8195,10 @@
<string name="FindByActivity__network_error_dialog">네트워크 오류가 발생했습니다. 나중에 다시 시도하세요.</string>
<!-- Title for an alert letting someone know that one of their linked devices is inactive. -->
<string name="LinkedDeviceInactiveMegaphone_title">미사용 연 기기</string>
<string name="LinkedDeviceInactiveMegaphone_title">미사용 연 기기</string>
<!-- Body for an alert letting someone know that one of their linked devices is inactive. The string placeholder is the name of the device, and the number placeholder is the number of days before device is unlinked. -->
<plurals name="LinkedDeviceInactiveMegaphone_body">
<item quantity="other">\'%1$s\'의 연 상태를 유지하려면 해당 기기에서 %2$d일 이내에 Signal을 세요.</item>
<item quantity="other">\'%1$s\'의 연 상태를 유지하려면 해당 기기에서 %2$d일 이내에 Signal을 열어주세요.</item>
</plurals>
<!-- Button label for an alert letting someone know that one of their linked devices is inactive. When clicked, the user will opt out of all future alerts. -->
<string name="LinkedDeviceInactiveMegaphone_dont_remind_button_label">알림 해제</string>
+1 -1
View File
@@ -6835,7 +6835,7 @@
<string name="ImageView__badge">Odznaka</string>
<string name="SubscribeFragment__cancel_subscription">Anuluj darowiznę cykliczną</string>
<string name="SubscribeFragment__confirm_cancellation">Potwierdzić anulowanie?</string>
<string name="SubscribeFragment__confirm_cancellation">Potwierdziasz anulowanie?</string>
<string name="SubscribeFragment__you_wont_be_charged_again">Nie zostaną już od Ciebie pobrane żadne środki. Odznaka zniknie z Twojego profilu z końcem okresu rozliczeniowego.</string>
<string name="SubscribeFragment__not_now">Nie teraz</string>
<string name="SubscribeFragment__confirm">Potwierdź</string>
+17 -2
View File
@@ -139,7 +139,13 @@
<item name="fixedRoundedCornerBottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners</item>
<item name="permissionsRationaleDialogTheme">@style/Theme.Signal.AlertDialog.Light.Cornered</item>
<item name="android:forceDarkAllowed" tools:targetApi="29">false</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
<!--
Off so the framework does not scrim a transparent system bar for legibility, hiding the content we
draw behind it. Set here rather than alongside enableEdgeToEdge() because dialog and bottom sheet
windows inherit this theme, and they are not covered by those calls.
-->
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item>
<!-- Material 3 -->
<item name="colorPrimary">@color/signal_colorPrimary</item>
@@ -236,7 +242,9 @@
<item name="fixedRoundedCornerBottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners</item>
<item name="permissionsRationaleDialogTheme">@style/Theme.Signal.AlertDialog.Dark.Cornered</item>
<item name="android:forceDarkAllowed" tools:targetApi="29">false</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
<!-- As above: no framework contrast scrim over the transparent system bars. -->
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item>
<item name="android:enforceStatusBarContrast" tools:targetApi="29">false</item>
<!-- Material 3 -->
<item name="colorPrimary">@color/signal_colorPrimary</item>
@@ -434,6 +442,12 @@
<item name="contactCheckboxBackground">@drawable/contact_selection_checkbox_dialog</item>
</style>
<!--
The navigationBarColor items below only apply under API 35. At and above, edge-to-edge is enforced and
the bar is always transparent, so BottomSheetDialog instead draws the sheet itself behind the bar and
pads its content by the inset. The colors are matched to the sheet backgroundTint so both paths look
the same.
-->
<style name="Widget.Signal.FixedRoundedCorners.Messages" parent="Widget.Signal.FixedRoundedCorners">
<item name="bottomSheetStyle">@style/Widget.Signal.FixedRoundedCorners.Messages.BottomSheet</item>
<item name="android:navigationBarColor" tools:targetApi="lollipop">@color/bottom_sheet_navigation_bar_surface</item>
@@ -533,6 +547,7 @@
<item name="android:singleLine">false</item>
</style>
<!-- As above: the navigationBarColor/statusBarColor items in this group only apply under API 35. -->
<style name="Theme.Signal.RoundedBottomSheet.Light">
<item name="bottomSheetStyle">@style/Widget.Signal.BottomSheet.Rounded</item>
@@ -0,0 +1,285 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import io.mockk.every
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.Hex
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.testutil.FakeStorageServiceRule
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import org.thoughtcrime.securesms.testutil.SystemOutLogger
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import org.whispersystems.signalservice.internal.storage.protos.StickerPackRecord
import org.whispersystems.signalservice.internal.storage.protos.StorageRecord
import java.util.UUID
/**
* Tests for [StorageSyncJob]. Remote storage is a real encrypt/decrypt round-trip against
* [FakeStorageServiceRule], and local storage is a real in-memory database.
*/
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class StorageSyncJobTest {
companion object {
/** The manifest version both sides sit at once setup's initial sync has run. */
private const val BASE_MANIFEST_VERSION = 2L
}
private val recipients = RecipientTestRule()
private val remoteStorage = FakeStorageServiceRule()
@get:Rule
val rules: RuleChain = RuleChain
.outerRule(recipients)
.around(remoteStorage)
@Before
fun setUp() {
Log.initialize(SystemOutLogger())
remoteStorage.stubDefaults(recipients.signalStore)
// We need to run an initial sync so that every test starts with local and remote already agreeing at [BASE_MANIFEST_VERSION].
// Without that, the default "All chats" chat folder shows up as a local-only ID in every test and muddies the assertions.
runInitialSync()
}
@Test
fun `given nothing has changed, when I run, then I leave both sides alone`() {
val result = runJob(StorageSyncJob.forLocalChange())
assertTrue(result.isSuccess)
assertEquals(0, remoteStorage.writeCount)
assertEquals(BASE_MANIFEST_VERSION, remoteStorage.manifest!!.version)
assertEquals(BASE_MANIFEST_VERSION, localManifestVersion())
}
@Test
fun `given no access to storage service, when I run, then I touch nothing`() {
every { recipients.signalStore.svr.hasPin() } returns false
SignalDatabase.recipients.rotateStorageId(recipients.createRecipient("Local Contact"))
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
assertEquals(0, remoteStorage.writeCount)
assertEquals(BASE_MANIFEST_VERSION, remoteStorage.manifest!!.version)
}
@Test
fun `given storage service is disabled, when I run, then I touch nothing`() {
every { recipients.signalStore.internal.storageServiceDisabled } returns true
SignalDatabase.recipients.rotateStorageId(recipients.createRecipient("Local Contact"))
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
assertEquals(0, remoteStorage.writeCount)
assertEquals(BASE_MANIFEST_VERSION, remoteStorage.manifest!!.version)
}
@Test
fun `given a remote-only contact, when I run, then I insert it locally`() {
val aci = ACI.from(UUID.randomUUID())
remoteStorage.addRemoteRecords(listOf(contactRecord(aci, ProfileName.fromParts("Remote", "Contact"))))
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
val inserted = SignalDatabase.recipients.getByAci(aci)
assertTrue(inserted.isPresent)
assertEquals(ProfileName.fromParts("Remote", "Contact"), Recipient.resolved(inserted.get()).profileName)
}
@Test
fun `given a remote-only contact, when I run, then I write nothing back and save their manifest`() {
remoteStorage.addRemoteRecords(listOf(contactRecord(ACI.from(UUID.randomUUID()), ProfileName.fromParts("Remote", "Contact"))))
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
assertEquals(0, remoteStorage.writeCount)
assertEquals(BASE_MANIFEST_VERSION + 1, remoteStorage.manifest!!.version)
assertEquals(BASE_MANIFEST_VERSION + 1, localManifestVersion())
}
@Test
fun `given a local-only contact, when I run, then I write it remotely`() {
val local = recipients.createRecipient("Local Contact")
SignalDatabase.recipients.rotateStorageId(local)
val result = runJob(StorageSyncJob.forLocalChange())
assertTrue(result.isSuccess)
assertEquals(1, remoteStorage.writeCount)
val contacts = remoteStorage.records.mapNotNull { it.proto.contact }
assertEquals(1, contacts.size)
assertEquals(Recipient.resolved(local).requireAci().toByteString(), contacts[0].aciBinary)
}
@Test
fun `given a local-only contact, when I run, then I save the new manifest on both sides`() {
SignalDatabase.recipients.rotateStorageId(recipients.createRecipient("Local Contact"))
val result = runJob(StorageSyncJob.forLocalChange())
assertTrue(result.isSuccess)
assertEquals(BASE_MANIFEST_VERSION + 1, remoteStorage.manifest!!.version)
assertEquals(BASE_MANIFEST_VERSION + 1, localManifestVersion())
}
@Test
fun `given the remote write hits a conflict, when I run, then I retry without changing remote state`() {
SignalDatabase.recipients.rotateStorageId(recipients.createRecipient("Local Contact"))
remoteStorage.failNextWriteWithConflict = true
val result = runJob(StorageSyncJob.forLocalChange())
assertTrue(result.isRetry)
assertEquals(BASE_MANIFEST_VERSION, remoteStorage.manifest!!.version)
}
@Test
fun `given the remote write hits a network error, when I run, then I retry without changing remote state`() {
SignalDatabase.recipients.rotateStorageId(recipients.createRecipient("Local Contact"))
remoteStorage.failNextWriteWithNetworkError = true
val result = runJob(StorageSyncJob.forLocalChange())
assertTrue(result.isRetry)
assertEquals(BASE_MANIFEST_VERSION, remoteStorage.manifest!!.version)
}
@Test
fun `given an unknown ID for a sticker pack I do not have, when I run, then I clear it out`() {
insertUnknownStorageId(StorageId.forStickerPack(Util.getSecretBytes(16)))
val result = runJob(StorageSyncJob.forLocalChange())
assertTrue(result.isSuccess)
assertTrue(SignalDatabase.unknownStorageIds.allUnknownIds.isEmpty())
assertEquals(0, remoteStorage.writeCount)
assertEquals(BASE_MANIFEST_VERSION, remoteStorage.manifest!!.version)
}
@Test
fun `given a newer remote manifest without my unknown sticker pack ID, when I run, then I clear it out`() {
insertUnknownStorageId(StorageId.forStickerPack(Util.getSecretBytes(16)))
remoteStorage.addRemoteRecords(listOf(contactRecord(ACI.from(UUID.randomUUID()), ProfileName.fromParts("Remote", "Contact"))))
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
assertTrue(SignalDatabase.unknownStorageIds.allUnknownIds.isEmpty())
assertEquals(BASE_MANIFEST_VERSION + 1, localManifestVersion())
}
@Test
fun `given an unknown ID for a sticker pack that exists remotely, when I run, then I promote it to a real sticker pack`() {
val packId = Util.getSecretBytes(16)
val record = stickerPackRecord(packId)
remoteStorage.addRemoteRecords(listOf(record))
insertUnknownStorageId(record.id)
val result = runJob(StorageSyncJob.forRemoteChange())
assertTrue(result.isSuccess)
assertTrue(SignalDatabase.unknownStorageIds.allUnknownIds.isEmpty())
val pack = SignalDatabase.stickers.getPackForStorageSync(StickerPackId(Hex.toStringCondensed(packId)))
assertNotNull(pack)
assertArrayEquals(record.id.raw, pack!!.storageServiceId!!.raw)
}
/**
* Gets us to a steady state: remote holds our account record at version 1, then a sync pushes up everything else
* the fresh database came with (the default chat folder), leaving both sides at [BASE_MANIFEST_VERSION].
*/
private fun runInitialSync() {
SignalDatabase.recipients.updateStorageId(recipients.self, StorageSyncHelper.generateKey())
Recipient.self().live().refresh()
val accountRecord = StorageSyncHelper.buildAccountRecord(ApplicationProvider.getApplicationContext(), Recipient.self())
remoteStorage.setRemoteState(listOf(accountRecord))
val result = runJob(StorageSyncJob.forLocalChange())
check(result.isSuccess) { "Initial sync failed!" }
check(remoteStorage.manifest!!.version == BASE_MANIFEST_VERSION) { "Initial sync left remote at ${remoteStorage.manifest!!.version}!" }
remoteStorage.resetCounters()
}
private fun localManifestVersion(): Long {
return recipients.signalStore.storageService.manifest.version
}
private fun contactRecord(aci: ACI, profileName: ProfileName): SignalStorageRecord {
return SignalStorageRecord(
id = StorageId.forContact(Util.getSecretBytes(16)),
proto = StorageRecord(
contact = ContactRecord(
aciBinary = aci.toByteString(),
givenName = profileName.givenName,
familyName = profileName.familyName,
whitelisted = true
)
)
)
}
private fun stickerPackRecord(packId: ByteArray): SignalStorageRecord {
return SignalStorageRecord(
id = StorageId.forStickerPack(Util.getSecretBytes(16)),
proto = StorageRecord(
stickerPack = StickerPackRecord(
packId = packId.toByteString(),
packKey = Util.getSecretBytes(32).toByteString(),
position = 1
)
)
)
}
/** Puts [id] in the unknown ID table, as if we'd synced it before we understood its type. */
private fun insertUnknownStorageId(id: StorageId) {
SignalDatabase.writableDatabase.withinTransaction {
SignalDatabase.unknownStorageIds.insert(listOf(SignalStorageRecord.forUnknown(id)))
}
}
private fun runJob(job: StorageSyncJob): Job.Result {
job.setContext(ApplicationProvider.getApplicationContext())
return job.run()
}
}
@@ -0,0 +1,276 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.testutil
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import okio.ByteString
import org.junit.rules.ExternalResource
import org.signal.core.models.storageservice.StorageKey
import org.signal.core.util.Util
import org.signal.network.NetworkResult
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.signal.network.service.StorageServiceService
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberSharingMode
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.storage.RecordIkm
import org.whispersystems.signalservice.api.storage.SignalStorageManifest
import org.whispersystems.signalservice.api.storage.SignalStorageRecord
import org.whispersystems.signalservice.api.storage.StorageServiceApi
import org.whispersystems.signalservice.internal.storage.protos.ReadOperation
import org.whispersystems.signalservice.internal.storage.protos.StorageItem
import org.whispersystems.signalservice.internal.storage.protos.StorageItems
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest
import org.whispersystems.signalservice.internal.storage.protos.WriteOperation
import java.io.IOException
import java.util.Currency
import kotlin.time.Duration.Companion.days
/**
* An in-memory stand-in for the storage service, installed over [SignalNetwork.storageService].
*
* Records are stored encrypted with [storageKey], exactly as they would be remotely, so tests get a real
* encrypt/decrypt round-trip. Seed remote state with [setRemoteState], and inspect the results of a sync
* with [manifest] and [records].
*/
class FakeStorageServiceRule(val storageKey: StorageKey = StorageKey(Util.getSecretBytes(32))) : ExternalResource() {
val api: StorageServiceApi = mockk()
/** The same higher-level service the app uses, pointed at this fake. Useful for building expected values. */
val service: StorageServiceService by lazy { StorageServiceService(api) }
var writeCount: Int = 0
private set
var readCount: Int = 0
private set
/** When true, the next write is rejected with a 409, as if someone else wrote first. */
var failNextWriteWithConflict: Boolean = false
/** When true, the next write is rejected with a network error. */
var failNextWriteWithNetworkError: Boolean = false
private var storedManifest: StorageManifest? = null
private val storedItems: MutableMap<ByteString, StorageItem> = LinkedHashMap()
private var seeding: Boolean = false
/** The current remote manifest, decrypted, or null if nothing has ever been written. */
val manifest: SignalStorageManifest?
get() = storedManifest?.let {
when (val result = service.getStorageManifest(storageKey)) {
is StorageServiceService.ManifestResult.Success -> result.manifest
else -> throw AssertionError("Failed to read back the manifest: $result")
}
}
/** The current remote records, decrypted. */
val records: List<SignalStorageRecord>
get() {
val manifest = this.manifest ?: return emptyList()
return when (val result = service.readStorageRecords(storageKey, manifest.recordIkm, manifest.storageIds)) {
is StorageServiceService.StorageRecordResult.Success -> result.records
else -> throw AssertionError("Failed to read back records: $result")
}
}
override fun before() {
every { api.getAuth() } returns NetworkResult.Success("auth")
every { api.getStorageManifest(any()) } answers { getStorageManifest() }
every { api.getStorageManifestIfDifferentVersion(any(), any()) } answers { getStorageManifestIfDifferentVersion(secondArg()) }
every { api.readStorageItems(any(), any()) } answers { readStorageItems(secondArg()) }
every { api.writeStorageItems(any(), any()) } answers { writeStorageItems(secondArg()) }
mockkObject(SignalNetwork)
every { SignalNetwork.storageService } returns api
}
override fun after() {
unmockkObject(SignalNetwork)
unmockkObject(RemoteConfig)
}
/**
* Stubs every [SignalStore] and [RemoteConfig] value that a storage service sync touches, with the storage
* key pointed at this fake and the local manifest backed by real read/write state.
*
* Sane defaults for everything -- individual tests can re-stub whatever they're actually exercising.
* Must be called after [MockSignalStoreRule] has been applied, i.e. from your test's setup.
*/
fun stubDefaults(store: MockSignalStoreRule) {
var localManifest = SignalStorageManifest.EMPTY
every { store.storageService.manifest } answers { localManifest }
every { store.storageService.manifest = any() } answers { localManifest = firstArg() }
every { store.storageService.storageKey } returns storageKey
every { store.storageService.storageKeyForInitialDataRestore } returns null
every { store.svr.hasPin() } returns true
every { store.svr.hasOptedOut() } returns false
every { store.account.isRegistered } returns true
every { store.account.isPrimaryDevice } returns true
every { store.account.isLinkedDevice } returns false
every { store.account.isMultiDevice } returns false
every { store.account.deviceId } returns 1
every { store.account.restoredAccountEntropyPool } returns false
every { store.account.restoredAccountEntropyPoolFromPrimary } returns false
every { store.account.pni } returns null
every { store.account.username } returns null
every { store.account.usernameLink } returns null
every { store.internal.storageServiceDisabled } returns false
// Read while building our own AccountRecord.
every { store.story.viewedReceiptsEnabled } returns false
every { store.story.userHasBeenNotifiedAboutStories } returns false
every { store.story.userHasViewedOnboardingStory } returns false
every { store.story.isFeatureDisabled } returns false
every { store.story.userHasSeenGroupStoryEducationSheet } returns false
every { store.uiHints.hasCompletedUsernameOnboarding() } returns false
every { store.uiHints.hasSeenAdminDeleteEducationDialog() } returns false
every { store.payments.mobileCoinPaymentsEnabled() } returns false
every { store.payments.paymentsEntropy } returns null
every { store.emoji.reactions } returns emptyList()
every { store.inAppPayments.getDisplayBadgesOnProfile() } returns false
every { store.inAppPayments.isDonationSubscriptionManuallyCancelled() } returns false
every { store.inAppPayments.isBackupSubscriptionManuallyCancelled() } returns false
every { store.inAppPayments.getRecurringDonationCurrency() } returns Currency.getInstance("USD")
every { store.inAppPayments.getSubscriber(any()) } returns null
every { store.backup.areBackupsEnabled } returns false
every { store.backup.backupTier } returns null
every { store.backup.backupTierInternalOverride } returns null
every { store.phoneNumberPrivacy.phoneNumberDiscoverabilityMode } returns PhoneNumberDiscoverabilityMode.DISCOVERABLE
every { store.phoneNumberPrivacy.phoneNumberSharingMode } returns PhoneNumberSharingMode.EVERYBODY
every { store.notificationProfile.manuallyEnabledProfile } returns 0
every { store.notificationProfile.manuallyEnabledUntil } returns 0
every { store.notificationProfile.manuallyDisabledAt } returns 0
every { store.releaseChannel.releaseChannelRecipientId } returns null
every { store.settings.isLinkPreviewsEnabled } returns true
every { store.settings.isPreferSystemContactPhotos } returns false
every { store.settings.universalExpireTimer } returns 0
every { store.settings.shouldKeepMutedChatsArchived() } returns false
every { store.settings.automaticVerificationEnabled } returns true
mockkObject(RemoteConfig)
every { RemoteConfig.messageQueueTime } returns 45.days.inWholeMilliseconds
}
/**
* Replaces all remote state with [records] at the provided manifest [version], as if another device had written it.
*/
fun setRemoteState(
records: List<SignalStorageRecord>,
version: Long = 1,
recordIkm: RecordIkm? = RecordIkm.generate(),
sourceDeviceId: Int = 2
) {
val manifest = SignalStorageManifest(
version = version,
sourceDeviceId = sourceDeviceId,
recordIkm = recordIkm,
storageIds = records.map { it.id }
)
seeding = true
try {
val result = service.resetAndWriteStorageRecords(storageKey, manifest, records.filterNot { it.isUnknown })
check(result is StorageServiceService.WriteStorageRecordsResult.Success) { "Failed to seed remote state: $result" }
} finally {
seeding = false
}
resetCounters()
}
/**
* Adds [records] to the existing remote state at the next manifest version, as if another device had written them.
*/
fun addRemoteRecords(records: List<SignalStorageRecord>) {
val current = manifest ?: error("There's no remote manifest yet! Call setRemoteState() first.")
val updated = SignalStorageManifest(
version = current.version + 1,
sourceDeviceId = 2,
recordIkm = current.recordIkm,
storageIds = current.storageIds + records.map { it.id }
)
seeding = true
try {
val result = service.writeStorageRecords(storageKey, updated, records.filterNot { it.isUnknown }, emptyList())
check(result is StorageServiceService.WriteStorageRecordsResult.Success) { "Failed to add remote records: $result" }
} finally {
seeding = false
}
}
/** Zeroes out [writeCount] and [readCount], so setup traffic doesn't count against a test's assertions. */
fun resetCounters() {
writeCount = 0
readCount = 0
}
private fun getStorageManifest(): NetworkResult<StorageManifest> {
return storedManifest?.let { NetworkResult.Success(it) } ?: statusCodeError(404)
}
private fun getStorageManifestIfDifferentVersion(version: Long): NetworkResult<StorageManifest> {
val current = storedManifest ?: return statusCodeError(404)
return if (current.version == version) {
statusCodeError(204)
} else {
NetworkResult.Success(current)
}
}
private fun readStorageItems(operation: ReadOperation): NetworkResult<StorageItems> {
readCount++
val items = operation.readKey.mapNotNull { storedItems[it] }
return NetworkResult.Success(StorageItems(items = items))
}
private fun writeStorageItems(operation: WriteOperation): NetworkResult<Unit> {
if (!seeding) {
writeCount++
if (failNextWriteWithConflict) {
failNextWriteWithConflict = false
return statusCodeError(409)
}
if (failNextWriteWithNetworkError) {
failNextWriteWithNetworkError = false
return NetworkResult.NetworkError(IOException("Test network failure"))
}
val expectedVersion = (storedManifest?.version ?: 0) + 1
if (operation.manifest!!.version != expectedVersion) {
return statusCodeError(409)
}
}
if (operation.clearAll) {
storedItems.clear()
}
operation.deleteKey.forEach { storedItems.remove(it) }
operation.insertItem.forEach { storedItems[it.key] = it }
storedManifest = operation.manifest
return NetworkResult.Success(Unit)
}
private fun <T> statusCodeError(code: Int): NetworkResult<T> {
return NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(code))
}
}
@@ -14,12 +14,19 @@ import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.EmojiValues
import org.thoughtcrime.securesms.keyvalue.InAppPaymentValues
import org.thoughtcrime.securesms.keyvalue.InternalValues
import org.thoughtcrime.securesms.keyvalue.MiscellaneousValues
import org.thoughtcrime.securesms.keyvalue.NotificationProfileValues
import org.thoughtcrime.securesms.keyvalue.PaymentsValues
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.RegistrationValues
import org.thoughtcrime.securesms.keyvalue.ReleaseChannelValues
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.StorageServiceValues
import org.thoughtcrime.securesms.keyvalue.StoryValues
import org.thoughtcrime.securesms.keyvalue.SvrValues
import org.thoughtcrime.securesms.keyvalue.UiHintValues
import kotlin.reflect.KClass
/**
@@ -61,6 +68,27 @@ class MockSignalStoreRule(private val relaxed: Set<KClass<*>> = emptySet()) : Ex
lateinit var releaseChannel: ReleaseChannelValues
private set
lateinit var storageService: StorageServiceValues
private set
lateinit var internal: InternalValues
private set
lateinit var misc: MiscellaneousValues
private set
lateinit var story: StoryValues
private set
lateinit var uiHints: UiHintValues
private set
lateinit var payments: PaymentsValues
private set
lateinit var notificationProfile: NotificationProfileValues
private set
override fun before() {
account = mockk(relaxed = relaxed.contains(AccountValues::class), relaxUnitFun = true)
phoneNumberPrivacy = mockk(relaxed = relaxed.contains(PhoneNumberPrivacyValues::class), relaxUnitFun = true)
@@ -71,6 +99,13 @@ class MockSignalStoreRule(private val relaxed: Set<KClass<*>> = emptySet()) : Ex
backup = mockk(relaxed = relaxed.contains(BackupValues::class), relaxUnitFun = true)
settings = mockk(relaxed = relaxed.contains(SettingsValues::class), relaxUnitFun = true)
releaseChannel = mockk(relaxed = relaxed.contains(ReleaseChannelValues::class), relaxUnitFun = true)
storageService = mockk(relaxed = relaxed.contains(StorageServiceValues::class), relaxUnitFun = true)
internal = mockk(relaxed = relaxed.contains(InternalValues::class), relaxUnitFun = true)
misc = mockk(relaxed = relaxed.contains(MiscellaneousValues::class), relaxUnitFun = true)
story = mockk(relaxed = relaxed.contains(StoryValues::class), relaxUnitFun = true)
uiHints = mockk(relaxed = relaxed.contains(UiHintValues::class), relaxUnitFun = true)
payments = mockk(relaxed = relaxed.contains(PaymentsValues::class), relaxUnitFun = true)
notificationProfile = mockk(relaxed = relaxed.contains(NotificationProfileValues::class), relaxUnitFun = true)
mockkObject(SignalStore)
every { SignalStore.account } returns account
@@ -82,6 +117,13 @@ class MockSignalStoreRule(private val relaxed: Set<KClass<*>> = emptySet()) : Ex
every { SignalStore.backup } returns backup
every { SignalStore.settings } returns settings
every { SignalStore.releaseChannel } returns releaseChannel
every { SignalStore.storageService } returns storageService
every { SignalStore.internal } returns internal
every { SignalStore.misc } returns misc
every { SignalStore.story } returns story
every { SignalStore.uiHints } returns uiHints
every { SignalStore.payments } returns payments
every { SignalStore.notificationProfile } returns notificationProfile
}
override fun after() {
@@ -79,7 +79,7 @@ class RecipientTestRule : TestRule {
every { signalStore.registration.isRegistrationComplete } returns true
self = insertRecipient(selfAci, ProfileName.fromParts("Tester", "McTesterson"))
self = insertRecipient(selfAci, ProfileName.fromParts("Tester", "McTesterson"), e164 = selfE164)
}
override fun after() {
@@ -180,8 +180,12 @@ class RecipientTestRule : TestRule {
Recipient.live(id).refresh()
}
private fun insertRecipient(aci: ACI, profileName: ProfileName, profileSharing: Boolean = true): RecipientId {
val id = SignalDatabase.recipients.getOrInsertFromServiceId(aci)
private fun insertRecipient(aci: ACI, profileName: ProfileName, profileSharing: Boolean = true, e164: String? = null): RecipientId {
val id = if (e164 != null) {
SignalDatabase.recipients.getAndPossiblyMerge(aci, e164)
} else {
SignalDatabase.recipients.getOrInsertFromServiceId(aci)
}
SignalDatabase.recipients.setProfileName(id, profileName)
SignalDatabase.recipients.setProfileKeyIfAbsent(id, ProfileKey(Random.nextBytes(32)))
SignalDatabase.recipients.setCapabilities(id, SignalServiceProfile.Capabilities(true, true, true))
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.testutil
import androidx.test.core.app.ApplicationProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.rules.ExternalResource
@@ -18,6 +19,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.JdbcSqliteDatabase
import org.thoughtcrime.securesms.testing.TestSignalDatabase
import net.zetetic.database.sqlcipher.SQLiteDatabase as SQLCipherSQLiteDatabase
class SignalDatabaseRule : ExternalResource() {
@@ -39,6 +41,22 @@ class SignalDatabaseRule : ExternalResource() {
mockkObject(SignalDatabase)
every { SignalDatabase.instance } returns signalDatabase
every { SignalDatabase.inTransaction } answers { signalDatabase.signalWritableDatabase.inTransaction() }
every { SignalDatabase.rawDatabase } returns rawDatabaseDelegatingTransactionsToTestDatabase()
}
/**
* The test database is backed by sqlite-jdbc rather than SQLCipher, so there's no real
* [SQLCipherSQLiteDatabase] to hand out. Callers that grab [SignalDatabase.rawDatabase] do so to control
* transactions, so that's what we wire up here. Everything else is relaxed and does nothing.
*/
private fun rawDatabaseDelegatingTransactionsToTestDatabase(): SQLCipherSQLiteDatabase {
return mockk(relaxed = true) {
every { beginTransaction() } answers { writeableDatabase.beginTransaction() }
every { beginTransactionNonExclusive() } answers { writeableDatabase.beginTransactionNonExclusive() }
every { setTransactionSuccessful() } answers { writeableDatabase.setTransactionSuccessful() }
every { endTransaction() } answers { writeableDatabase.endTransaction() }
every { inTransaction() } answers { writeableDatabase.inTransaction() }
}
}
override fun after() {
+1 -1
View File
@@ -1,5 +1,5 @@
service_ips=new String[]{"13.248.212.111","76.223.92.165"}
storage_ips=new String[]{"142.251.45.179"}
storage_ips=new String[]{"142.250.217.147"}
cdn_ips=new String[]{"18.238.49.106","18.238.49.6","18.238.49.66","18.238.49.90"}
cdn2_ips=new String[]{"104.18.10.47","104.18.11.47"}
cdn3_ips=new String[]{"104.18.10.47","104.18.11.47"}
@@ -28,8 +28,9 @@ fun Window.initializeScreenshotSecurity() {
/**
* Dialog-window analog of [androidx.activity.enableEdgeToEdge]: lays the window out edge-to-edge with
* transparent (or scrimmed, pre-29) system bars, matching what targetSdk 36 enforces. Dialog windows are
* not covered by the activity-level call in BaseActivity, so every non-floating dialog must opt in itself.
* transparent (or scrimmed, pre-29) system bars, matching what the framework enforces on API 35+. Dialog
* windows are not covered by the activity-level call in BaseActivity, so every non-floating dialog must opt
* in itself.
*
* Bar icon appearance (`windowLightStatusBar` / `windowLightNavigationBar`) is left to the window's theme,
* which stays honored under edge-to-edge.
@@ -37,6 +38,12 @@ fun Window.initializeScreenshotSecurity() {
@Suppress("DEPRECATION")
fun Window.enableEdgeToEdge() {
WindowCompat.setDecorFitsSystemWindows(this, false)
if (Build.VERSION.SDK_INT >= 35) {
// Edge-to-edge is enforced, which forces the bars transparent and makes both setters no-ops.
return
}
statusBarColor = Color.TRANSPARENT
navigationBarColor = when {
Build.VERSION.SDK_INT >= 29 -> Color.TRANSPARENT
+1
View File
@@ -7,6 +7,7 @@
<item name="bottomSheetStyle">@style/Widget.CoreUi.FixedRoundedCorners.BottomSheet</item>
<item name="android:windowEnterAnimation">@anim/slide_from_bottom</item>
<item name="android:windowExitAnimation">@anim/slide_to_bottom</item>
<!-- Only applies under API 35, otherwise edge-to-edge enforcement keeps the bar transparent and the sheet draws behind it. -->
<item name="android:navigationBarColor" tools:targetApi="lollipop">@color/signal_bottom_sheet_navigation_bar</item>
</style>
@@ -107,9 +107,9 @@
<string name="UsernameScannedDialog__username_dialog_go_to_chat_button">채팅으로 이동</string>
<!-- The title of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="LinkedDeviceScannedDialog__device_link_dialog_title">기기를 연할까요?</string>
<string name="LinkedDeviceScannedDialog__device_link_dialog_title">기기를 연할까요?</string>
<!-- The body of a dialog notifying that the user scanned a QR code that could be used to link a Signal device. -->
<string name="LinkedDeviceScannedDialog__it_looks_like_youre_trying">Signal 기기를 연하려는 것 같네요. 계속을 탭한 다음 \'새 기기 연\'을 탭하고 QR 코드를 다시 스캔하세요.</string>
<string name="LinkedDeviceScannedDialog__it_looks_like_youre_trying">Signal 기기를 연하려고 하시는 것 같네요. 계속을 탭한 다음 \'새 기기 연\'을 탭하고 QR 코드를 다시 스캔하세요.</string>
<!-- The label of a dialog asking the user if they would like to continue to the linked device settings screen. -->
<string name="LinkedDeviceScannedDialog__device_link_dialog_continue">계속</string>
</resources>
@@ -435,7 +435,7 @@
<!-- Title for the screen asking the user to grant the notification permission -->
<string name="AllowNotificationsScreen__allow_notifications">Permitir notificaciones</string>
<!-- Description for the screen asking the user to grant the notification permission -->
<string name="AllowNotificationsScreen__signal_would_like_to_request_the_notification_permission">Signal necesita permiso para mostrarte notificaciones. Así podrás recibir alertas en tu dispositivo cuando te lleguen mensajes nuevos.</string>
<string name="AllowNotificationsScreen__signal_would_like_to_request_the_notification_permission">Signal necesita permiso para mostrarte notificaciones. Así podrás recibir avisos en tu dispositivo cuando te lleguen mensajes nuevos.</string>
<!-- Button label that dismisses the notification permission request without granting it -->
<string name="AllowNotificationsScreen__not_now">Ahora no</string>
<!-- Button label that proceeds with the notification permission request -->
@@ -62,7 +62,7 @@
<!-- Menu option to use a proxy -->
<string name="RegistrationActivity_use_proxy">프록시 사용하기</string>
<!-- Menu option to link this as a device -->
<string name="RegistrationActivity_link_device">기기 연</string>
<string name="RegistrationActivity_link_device">기기 연</string>
<!-- Clickable text that opens a corresponding URL -->
<string name="RegistrationActivity_learn_more">자세히 알아보기</string>
@@ -467,13 +467,13 @@
<string name="MessageSyncScreen__continue_without_messages">메시지 없이 계속하기</string>
<!-- Title for the screen that shows a QR code the user scans with their existing primary device to link this device as a secondary -->
<string name="LinkAccountScreen__scan_this_code_to_link_your_account">이 코드를 스캔하여 계정을 연하세요</string>
<string name="LinkAccountScreen__scan_this_code_to_link_your_account">이 코드를 스캔하여 계정을 연하세요</string>
<!-- Step in the link-account instructions: open Signal on the user\'s existing phone -->
<string name="LinkAccountScreen__open_signal_on_your_phone">휴대전화에서 Signal을 엽니다.</string>
<!-- Step in the link-account instructions: tap profile picture to open Signal Settings -->
<string name="LinkAccountScreen__tap_your_profile_picture_to_open_signal_settings">프로필 사진을 탭하여 Signal 설정을 엽니다.</string>
<!-- Step in the link-account instructions: navigate to the Linked devices section and tap Link new device. The quoted strings should match the button/menu labels in the primary device\'s settings. -->
<string name="LinkAccountScreen__tap_linked_devices_and_link_new_device">\'연결된 기기\'와 \'새 기기 연\'을 차례로 탭합니다.</string>
<string name="LinkAccountScreen__tap_linked_devices_and_link_new_device">\'연 기기\'와 \'새 기기 연\'을 차례로 탭합니다.</string>
<!-- Button label opening help content for users who can\'t complete the link-account steps -->
<string name="LinkAccountScreen__get_help_with_these_steps">도움이 필요하신가요?</string>
<!-- Accessibility content description for the button that enlarges the QR code to full screen -->
@@ -489,11 +489,11 @@
<!-- Button to retry generating the QR code after it failed to load -->
<string name="LinkAccountScreen__retry">재시도</string>
<!-- Progress dialog message shown while this device is being registered as a linked device -->
<string name="LinkAccountScreen__linking_device">기기를 연하는 중…</string>
<string name="LinkAccountScreen__linking_device">기기를 연하는 중…</string>
<!-- Progress dialog message shown while waiting for the primary device to prepare the message backup -->
<string name="LinkAccountScreen__waiting_for_your_other_device">다른 기기를 기다리는 중…</string>
<!-- Error dialog message shown when linking this device fails -->
<string name="LinkAccountScreen__error_linking_device">기기 연결 도중 오류가 발생했습니다.</string>
<string name="LinkAccountScreen__error_linking_device">기기동하는 데 오류가 발생했습니다.</string>
<!-- Title of the confirmation dialog shown when the scanned QR code belongs to a different account than the one this device is currently linked to -->
<string name="LinkAccountScreen__delete_app_data_question">앱 데이터를 삭제할까요?</string>
<!-- Body of the confirmation dialog warning that continuing will erase all local data before relinking to a different account -->
@@ -261,13 +261,23 @@ public class StickyHeaderGridLayoutManager extends RecyclerView.LayoutManager im
requestLayout();
}
/**
* <p>Rows are laid out, and recycled, against {@code getPaddingTop()} and
* {@code getHeight() - getPaddingBottom()}. That is the right boundary while the padding clips, but when it
* does not -- the usual edge-to-edge setup, where the list is padded by the navigation bar inset and draws
* behind it -- the padded strips are visible, so a row treated as off screen there pops into existence in
* plain sight. Extending the boundaries by the padding makes rows scroll through those strips instead.
*/
private int getExtraLayoutSpace(RecyclerView.State state) {
if (state.hasTargetScrollPosition()) {
return getHeight();
}
else {
else if (getClipToPadding()) {
return 0;
}
else {
return Math.max(getPaddingTop(), getPaddingBottom());
}
}
@Override