Skip to content

Commit 752d2d7

Browse files
feat: notify the user when a scene writes to the clipboard (#9660)
SEC-001: copyToClipboard had no user-gesture gate, permission, or feedback, so a scene could silently overwrite the OS clipboard. Raise a top-of-screen toast on every scene-driven clipboard write. Collapse repeats in NewNotificationController: only one clipboard toast is on screen or queued at a time, so a scene writing every tick cannot grow a backlog. The slot frees as soon as the toast is dismissed or expires, so the next write raises a fresh toast with no cooldown.
1 parent a01cb67 commit 752d2d7

8 files changed

Lines changed: 129 additions & 0 deletions

File tree

Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/RestrictedActionsAPIImplementation.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
using Utility.Arch;
1111
using DCL.ExternalUrlPrompt;
1212
using DCL.NftPrompt;
13+
using DCL.NotificationsBus;
14+
using DCL.NotificationsBus.NotificationTypes;
1315
using DCL.SceneRuntime.Apis.RestrictedActionsApi;
1416
using DCL.TeleportPrompt;
1517
using DCL.Utilities;
@@ -296,6 +298,11 @@ private async UniTask CopyToClipboardAsync(string text)
296298
{
297299
await UniTask.SwitchToMainThread();
298300
systemClipboard.Set(text);
301+
302+
// Raised on every write so a clipboard change driven by the scene, instead of by the user, is never
303+
// silent: whatever the user had copied is gone and pasting now yields scene-controlled text. A scene
304+
// can write on every tick, so the type is collapsible — the toast never queues more than one deep.
305+
NotificationsBusController.Instance.AddNotification(new SceneClipboardWriteNotification());
299306
}
300307

301308
/// <summary>

Explorer/Assets/DCL/Infrastructure/CrdtEcsBridge/JsModulesImplementation/RestrictedActions/Tests/RestrictedActionsAPIImplementationShould.cs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using DCL.ECSComponents;
88
using DCL.ExternalUrlPrompt;
99
using DCL.NftPrompt;
10+
using DCL.NotificationsBus;
11+
using DCL.NotificationsBus.NotificationTypes;
1012
using DCL.TeleportPrompt;
1113
using DCL.UI;
1214
using Decentraland.Kernel.Apis;
@@ -33,12 +35,17 @@ public class RestrictedActionsAPIImplementationShould
3335
private ISystemClipboard systemClipboard;
3436
private IExplorerUiActions explorerUiActions;
3537
private World sceneWorld;
38+
private int clipboardNotificationsReceived;
3639

3740
[SetUp]
3841
public void SetUp()
3942
{
4043
EcsTestsUtils.SetUpFeaturesRegistry();
4144

45+
NotificationsBusController.Initialize(new NotificationsBusController());
46+
clipboardNotificationsReceived = 0;
47+
NotificationsBusController.Instance.SubscribeToNotificationTypeReceived(NotificationType.INTERNAL_SCENE_CLIPBOARD_WRITE, _ => clipboardNotificationsReceived++);
48+
4249
mvcManager = Substitute.For<IMVCManager>();
4350
sceneStateProvider = Substitute.For<ISceneStateProvider>();
4451
sceneStateProvider.IsCurrent.Returns(true);
@@ -78,6 +85,7 @@ public void TearDown()
7885
{
7986
World.Destroy(sceneWorld);
8087
EcsTestsUtils.TearDownFeaturesRegistry();
88+
NotificationsBusController.Reset();
8189
}
8290

8391
[Test]
@@ -272,6 +280,42 @@ public void CopyToClipboard()
272280
systemClipboard.Received(1).Set(TEST_TEXT);
273281
}
274282

283+
[Test]
284+
public void CopyToClipboard_NotifiesTheUser()
285+
{
286+
// Act
287+
restrictedActionsAPIImplementation.TryCopyToClipboard("Ia Ia! Cthulhu Ftaghn!");
288+
289+
// Assert
290+
Assert.AreEqual(1, clipboardNotificationsReceived);
291+
}
292+
293+
[Test]
294+
public void CopyToClipboard_NotifiesOnEveryWrite()
295+
{
296+
// Act: a scene calling from onUpdate writes on every tick
297+
for (var i = 0; i < 10; i++)
298+
restrictedActionsAPIImplementation.TryCopyToClipboard($"0xATTACKER{i}");
299+
300+
// Assert: the API reports every write; collapsing repeats into a single toast is the
301+
// notification controller's job, so that it can uncollapse as soon as the toast is gone.
302+
systemClipboard.Received(10).Set(Arg.Any<string>());
303+
Assert.AreEqual(10, clipboardNotificationsReceived);
304+
}
305+
306+
[Test]
307+
public void CopyToClipboard_DoesNotNotify_WhenSceneIsNotCurrent()
308+
{
309+
// Arrange
310+
sceneStateProvider.IsCurrent.Returns(false);
311+
312+
// Act
313+
restrictedActionsAPIImplementation.TryCopyToClipboard("This should not be copied");
314+
315+
// Assert
316+
Assert.AreEqual(0, clipboardNotificationsReceived);
317+
}
318+
275319
[Test]
276320
public void CopyToClipboard_DoesNotCopy_WhenSceneIsNotCurrent()
277321
{

Explorer/Assets/DCL/Notifications/Assets/NotificationIcons.asset

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ MonoBehaviour:
6565
value: {fileID: 21300000, guid: be351c6750a9f4b3c92f55ba96046a81, type: 3}
6666
- key: 57
6767
value: {fileID: 21300000, guid: 68f219c76f1104541b1be7ee15787e14, type: 3}
68+
- key: 58
69+
value: {fileID: 21300000, guid: 11e50157680834b92b7f75220b425747, type: 3}
6870
defaultIcon: {fileID: 21300000, guid: 93b9e2f69bc2e4cada2159afb0632935, type: 3}
6971
notificationIconBackgrounds:
7072
- key: 28

Explorer/Assets/DCL/Notifications/NewNotification/NewNotificationController.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,24 @@ public class NewNotificationController : ControllerBase<NewNotificationView>
2626
private static readonly int HIDE_TRIGGER = Animator.StringToHash("Hide");
2727
private static readonly TimeSpan TIME_BEFORE_HIDE_NOTIFICATION_TIME_SPAN = TimeSpan.FromSeconds(5f);
2828

29+
/// <summary>
30+
/// Types that carry a standing condition rather than a one-off event, so a second one while the first is
31+
/// still on screen would say nothing new. Only one instance of these is ever queued or displayed at a
32+
/// time; further ones are dropped instead of piling up behind it.
33+
/// </summary>
34+
private static readonly List<NotificationType> COLLAPSIBLE_NOTIFICATION_TYPES = new ()
35+
{
36+
NotificationType.INTERNAL_SCENE_CLIPBOARD_WRITE,
37+
};
38+
2939
private readonly NotificationIconTypes notificationIconTypes;
3040
private readonly NotificationDefaultThumbnails notificationDefaultThumbnails;
3141
private readonly NftTypeIconSO rarityBackgroundMapping;
3242
private readonly IProfileRepository profileRepository;
3343
private readonly ImageControllerProvider imageControllerProvider;
3444
private readonly Queue<INotification> notificationQueue = new ();
3545
private bool isDisplaying;
46+
private NotificationType? displayingNotificationType;
3647
private ImageController? thumbnailImageController;
3748
private ImageController badgeThumbnailImageController;
3849
private ImageController friendsThumbnailImageController;
@@ -93,6 +104,10 @@ private void StopAnimation()
93104
cts.SafeCancelAndDispose();
94105
cts = new CancellationTokenSource();
95106
cts.Token.ThrowIfCancellationRequested();
107+
108+
// Dismissing hands the screen slot back immediately — the fade-out that follows is cosmetic, so a
109+
// collapsible type must not stay suppressed for its duration.
110+
displayingNotificationType = null;
96111
}
97112

98113
private void ClickedNotification(NotificationType notificationType, INotification notification)
@@ -104,11 +119,32 @@ private void ClickedNotification(NotificationType notificationType, INotificatio
104119
private void QueueNewNotification(INotification newNotification)
105120
{
106121
ReportHub.Log(ReportCategory.GIFTING, $"{newNotification.Type}");
122+
123+
if (COLLAPSIBLE_NOTIFICATION_TYPES.Contains(newNotification.Type) && IsPending(newNotification.Type))
124+
return;
125+
107126
notificationQueue.Enqueue(newNotification);
108127

109128
if (!isDisplaying) { DisplayNewNotificationAsync().Forget(); }
110129
}
111130

131+
/// <summary>
132+
/// Whether a notification of this type is on screen right now or waiting behind the one that is.
133+
/// </summary>
134+
private bool IsPending(NotificationType type)
135+
{
136+
if (displayingNotificationType == type)
137+
return true;
138+
139+
foreach (INotification queued in notificationQueue)
140+
{
141+
if (queued.Type == type)
142+
return true;
143+
}
144+
145+
return false;
146+
}
147+
112148
private async UniTaskVoid DisplayNewNotificationAsync()
113149
{
114150
if (viewInstance == null)
@@ -118,11 +154,13 @@ private async UniTaskVoid DisplayNewNotificationAsync()
118154
{
119155
isDisplaying = true;
120156
INotification notification = notificationQueue.Dequeue();
157+
displayingNotificationType = notification.Type;
121158

122159
switch (notification.Type)
123160
{
124161
case NotificationType.INTERNAL_ARRIVED_TO_DESTINATION:
125162
case NotificationType.INTERNAL_SERVER_ERROR:
163+
case NotificationType.INTERNAL_SCENE_CLIPBOARD_WRITE:
126164
await ProcessArrivedNotificationAsync(notification);
127165
break;
128166
case NotificationType.COMMUNITY_VOICE_CHAT_STARTED:
@@ -158,6 +196,9 @@ private async UniTaskVoid DisplayNewNotificationAsync()
158196
await ProcessDefaultNotificationAsync(notification);
159197
break;
160198
}
199+
200+
// The natural-expiry counterpart to the reset in StopAnimation, which covers early dismissal.
201+
displayingNotificationType = null;
161202
}
162203

163204
isDisplaying = false;

Explorer/Assets/DCL/Notifications/NotificationsMenu/NotificationsPanelController.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public class NotificationsPanelController : ControllerBase<NotificationsMenuView
4141
NotificationType.COMMUNITY_DEEP_LINK,
4242
NotificationType.INTERNAL_DEFAULT_SUCCESS,
4343
NotificationType.INTERNAL_SERVER_ERROR,
44+
NotificationType.INTERNAL_SCENE_CLIPBOARD_WRITE,
4445
};
4546

4647
private readonly NotificationsRequestController notificationsRequestController;

Explorer/Assets/DCL/NotificationsBus/NotificationTypes/NotificationType.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,5 +73,7 @@ public enum NotificationType
7373
BAN_WARNING,
7474
BANNED,
7575
BAN_LIFTED,
76+
77+
INTERNAL_SCENE_CLIPBOARD_WRITE,
7678
}
7779
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
2+
namespace DCL.NotificationsBus.NotificationTypes
3+
{
4+
/// <summary>
5+
/// An internal notification used to let the user know that the scene they are in wrote to the system clipboard,
6+
/// discarding whatever they had copied before.
7+
/// It will appear at the top of the screen, and not in the notifications feed.
8+
/// </summary>
9+
public class SceneClipboardWriteNotification : NotificationBase
10+
{
11+
private const string HEADER_TEXT = "A scene has replaced your clipboard content";
12+
13+
public override string GetHeader() =>
14+
HEADER_TEXT;
15+
16+
public SceneClipboardWriteNotification()
17+
{
18+
Type = NotificationType.INTERNAL_SCENE_CLIPBOARD_WRITE;
19+
}
20+
}
21+
}

Explorer/Assets/DCL/NotificationsBus/NotificationTypes/SceneClipboardWriteNotification.cs.meta

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)