Skip to content

Commit c359552

Browse files
committed
Make YouTube link previews opt-in
The conversation screen auto-fetches YouTube thumbnails from img.youtube.com as soon as a conversation containing a YouTube link is viewed, disclosing the viewer's IP address and read time to Google with no opt-out. Gate the feature behind a new YouTube link previews toggle in a Privacy settings sub-screen, disabled by default. The preference is re-read when the conversation screen is foregrounded, so toggling it applies to open conversations without reopening them.
1 parent bc4b055 commit c359552

32 files changed

Lines changed: 566 additions & 56 deletions

app/src/androidTest/java/com/android/messaging/ui/appsettings/general/ui/AppSettingsScreenTest.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ class AppSettingsScreenTest {
180180
appSettings = AppSettingsUiState(),
181181
onAction = screenModel::onAction,
182182
onNavigateBack = {},
183+
onPrivacyClick = {},
183184
isTopLevel = true,
184185
onAdvancedClick = { advancedClicks += 1 },
185186
)
@@ -203,6 +204,7 @@ class AppSettingsScreenTest {
203204
appSettings = AppSettingsUiState(),
204205
onAction = screenModel::onAction,
205206
onNavigateBack = {},
207+
onPrivacyClick = {},
206208
isTopLevel = false,
207209
onAdvancedClick = null,
208210
)
@@ -226,6 +228,7 @@ class AppSettingsScreenTest {
226228
appSettings = appSettings,
227229
onAction = screenModel::onAction,
228230
onNavigateBack = {},
231+
onPrivacyClick = {},
229232
)
230233
}
231234
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.android.messaging.ui.appsettings.privacy.ui
2+
3+
import androidx.activity.ComponentActivity
4+
import androidx.compose.ui.test.assertIsDisplayed
5+
import androidx.compose.ui.test.assertIsOff
6+
import androidx.compose.ui.test.isToggleable
7+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
8+
import androidx.compose.ui.test.onNodeWithText
9+
import androidx.compose.ui.test.performClick
10+
import com.android.messaging.R
11+
import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState
12+
import com.android.messaging.ui.appsettings.screen.SettingsScreenModel
13+
import com.android.messaging.ui.appsettings.screen.model.SettingsAction as Action
14+
import com.android.messaging.ui.core.AppTheme
15+
import io.mockk.mockk
16+
import io.mockk.verify
17+
import org.junit.Before
18+
import org.junit.Rule
19+
import org.junit.Test
20+
21+
class PrivacySettingsScreenTest {
22+
23+
@get:Rule
24+
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
25+
26+
private lateinit var screenModel: SettingsScreenModel
27+
28+
@Before
29+
fun setup() {
30+
screenModel = mockk(relaxed = true)
31+
}
32+
33+
@Test
34+
fun youTubeLinkPreviewsToggle_displaysTitleSummaryAndDefaultsToOff() {
35+
setContent()
36+
37+
val title = composeTestRule.activity.getString(
38+
R.string.youtube_link_previews_pref_title,
39+
)
40+
val summary = composeTestRule.activity.getString(
41+
R.string.youtube_link_previews_pref_summary,
42+
)
43+
composeTestRule.onNodeWithText(title).assertIsDisplayed()
44+
composeTestRule.onNodeWithText(summary).assertIsDisplayed()
45+
composeTestRule.onNode(isToggleable()).assertIsOff()
46+
}
47+
48+
@Test
49+
fun youTubeLinkPreviewsToggle_delegatesToScreenModel() {
50+
setContent()
51+
52+
val title = composeTestRule.activity.getString(
53+
R.string.youtube_link_previews_pref_title,
54+
)
55+
composeTestRule.onNodeWithText(title).performClick()
56+
57+
verify(exactly = 1) {
58+
screenModel.onAction(Action.YouTubeLinkPreviewsChanged(true))
59+
}
60+
}
61+
62+
@Test
63+
fun youTubeLinkPreviewsToggle_whenEnabled_delegatesDisable() {
64+
setContent(
65+
appSettings = AppSettingsUiState(youTubeLinkPreviewsEnabled = true),
66+
)
67+
68+
val title = composeTestRule.activity.getString(
69+
R.string.youtube_link_previews_pref_title,
70+
)
71+
composeTestRule.onNodeWithText(title).performClick()
72+
73+
verify(exactly = 1) {
74+
screenModel.onAction(Action.YouTubeLinkPreviewsChanged(false))
75+
}
76+
}
77+
78+
private fun setContent(
79+
appSettings: AppSettingsUiState = AppSettingsUiState(),
80+
) {
81+
composeTestRule.setContent {
82+
AppTheme {
83+
PrivacySettingsScreen(
84+
appSettings = appSettings,
85+
onAction = screenModel::onAction,
86+
onNavigateBack = {},
87+
)
88+
}
89+
}
90+
}
91+
}

app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ private fun outgoingMessage(text: String): ConversationMessageUiModel {
239239
canForwardMessage = true,
240240
canResendMessage = false,
241241
canSaveAttachments = false,
242+
isYouTubePreviewEnabled = false,
242243
mmsDownload = null,
243244
mmsSubject = null,
244245
protocol = ConversationMessageUiModel.Protocol.SMS,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.android.messaging.data.appsettings.repository
2+
3+
import com.android.messaging.FactoryTestAccess
4+
import com.android.messaging.data.appsettings.model.AppBooleanPref
5+
import com.android.messaging.data.debug.DebugFeaturesProvider
6+
import com.android.messaging.testutil.installTestFactory
7+
import io.mockk.mockk
8+
import kotlinx.coroutines.ExperimentalCoroutinesApi
9+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
10+
import kotlinx.coroutines.test.runTest
11+
import org.junit.After
12+
import org.junit.Assert.assertFalse
13+
import org.junit.Assert.assertTrue
14+
import org.junit.Before
15+
import org.junit.Test
16+
import org.junit.runner.RunWith
17+
import org.robolectric.RobolectricTestRunner
18+
import org.robolectric.RuntimeEnvironment
19+
20+
@OptIn(ExperimentalCoroutinesApi::class)
21+
@RunWith(RobolectricTestRunner::class)
22+
internal class AppSettingsRepositoryImplTest {
23+
24+
private val context = RuntimeEnvironment.getApplication().applicationContext
25+
26+
private val repository = AppSettingsRepositoryImpl(
27+
context = context,
28+
ioDispatcher = UnconfinedTestDispatcher(),
29+
debugFeaturesProvider = mockk<DebugFeaturesProvider>(relaxed = true),
30+
)
31+
32+
@Before
33+
fun setUp() {
34+
installTestFactory(context = context)
35+
}
36+
37+
@After
38+
fun tearDown() {
39+
FactoryTestAccess.reset()
40+
}
41+
42+
@Test
43+
fun isYouTubeLinkPreviewsEnabled_defaultsToDisabled() = runTest {
44+
assertFalse(repository.isYouTubeLinkPreviewsEnabled())
45+
}
46+
47+
@Test
48+
fun isYouTubeLinkPreviewsEnabled_reflectsOptIn() = runTest {
49+
repository.setBooleanPref(
50+
pref = AppBooleanPref.YOUTUBE_LINK_PREVIEWS,
51+
enabled = true,
52+
)
53+
54+
assertTrue(repository.isYouTubeLinkPreviewsEnabled())
55+
assertTrue(repository.getAppSettings().youTubeLinkPreviewsEnabled)
56+
}
57+
}

app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModelTest.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.messagedetails
22

33
import android.content.ClipboardManager
44
import androidx.lifecycle.SavedStateHandle
5+
import com.android.messaging.data.appsettings.repository.AppSettingsRepository
56
import com.android.messaging.data.conversation.model.message.ConversationMessageDetails
67
import com.android.messaging.data.conversation.model.message.ConversationMessageDetailsResult
78
import com.android.messaging.data.conversation.repository.ConversationsRepository
@@ -28,6 +29,9 @@ internal class MessageDetailsViewModelTest {
2829
val mainDispatcherRule = MainDispatcherRule()
2930

3031
private val conversationsRepository = mockk<ConversationsRepository>()
32+
private val appSettingsRepository = mockk<AppSettingsRepository> {
33+
coEvery { isYouTubeLinkPreviewsEnabled() } returns false
34+
}
3135
private val messageDetailsUiStateMapper = mockk<MessageDetailsUiStateMapper>()
3236
private val clipboardManager = mockk<ClipboardManager>()
3337

@@ -58,6 +62,7 @@ internal class MessageDetailsViewModelTest {
5862
messageDetailsUiStateMapper.map(
5963
message = message,
6064
details = details,
65+
isYouTubePreviewEnabled = false,
6166
)
6267
} returns content
6368

@@ -79,6 +84,7 @@ internal class MessageDetailsViewModelTest {
7984
messageDetailsUiStateMapper.map(
8085
message = message,
8186
details = details,
87+
isYouTubePreviewEnabled = false,
8288
)
8389
}
8490
}
@@ -96,6 +102,7 @@ internal class MessageDetailsViewModelTest {
96102
messageDetailsUiStateMapper.map(
97103
message = null,
98104
details = null,
105+
isYouTubePreviewEnabled = false,
99106
)
100107
} returns MessageDetailsUiState.Unavailable
101108

@@ -128,6 +135,7 @@ internal class MessageDetailsViewModelTest {
128135
): MessageDetailsViewModel {
129136
return MessageDetailsViewModel(
130137
conversationsRepository = conversationsRepository,
138+
appSettingsRepository = appSettingsRepository,
131139
messageDetailsUiStateMapper = messageDetailsUiStateMapper,
132140
clipboardManager = clipboardManager,
133141
savedStateHandle = savedStateHandle,

app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/mapper/MessageDetailsUiStateMapperImplTest.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,17 @@ internal class MessageDetailsUiStateMapperImplTest {
2525
val message = mockk<ConversationMessageData>()
2626
val preview = mockk<ConversationMessageUiModel>()
2727

28-
every { conversationMessageUiModelMapper.map(data = message) } returns preview
28+
every {
29+
conversationMessageUiModelMapper.map(
30+
data = message,
31+
isYouTubePreviewEnabled = false,
32+
)
33+
} returns preview
2934

3035
val result = mapper.map(
3136
message = message,
3237
details = details(),
38+
isYouTubePreviewEnabled = false,
3339
)
3440

3541
val content = result as MessageDetailsUiState.Content
@@ -41,6 +47,7 @@ internal class MessageDetailsUiStateMapperImplTest {
4147
val result = mapper.map(
4248
message = null,
4349
details = details(),
50+
isYouTubePreviewEnabled = false,
4451
)
4552

4653
assertEquals(MessageDetailsUiState.Unavailable, result)
@@ -51,6 +58,7 @@ internal class MessageDetailsUiStateMapperImplTest {
5158
val result = mapper.map(
5259
message = mockk<ConversationMessageData>(),
5360
details = null,
61+
isYouTubePreviewEnabled = false,
5462
)
5563

5664
assertEquals(MessageDetailsUiState.Unavailable, result)

app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegateImplTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.android.messaging.ui.conversation.messages.delegate
22

33
import androidx.core.net.toUri
4+
import com.android.messaging.data.appsettings.repository.AppSettingsRepository
45
import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository
56
import com.android.messaging.data.conversation.repository.ConversationsRepository
67
import com.android.messaging.domain.photoviewer.usecase.ResolveConversationPhotoViewerInitialOccurrenceIndex
@@ -23,6 +24,7 @@ internal class ConversationMessagesDelegateImplTest {
2324
mockk<ResolveConversationPhotoViewerInitialOccurrenceIndex>()
2425
private val delegate = ConversationMessagesDelegateImpl(
2526
conversationsRepository = mockk<ConversationsRepository>(),
27+
appSettingsRepository = mockk<AppSettingsRepository>(),
2628
resolveInitialPhotoOccurrenceIndex = resolveInitialPhotoOccurrenceIndex,
2729
conversationMessageUiModelMapper = mockk<ConversationMessageUiModelMapper>(),
2830
conversationVCardAttachmentUiModelMapper =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.android.messaging.ui.conversation.messages.ui.message
2+
3+
import androidx.core.net.toUri
4+
import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment
5+
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel
6+
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel
7+
import kotlinx.collections.immutable.ImmutableList
8+
import kotlinx.collections.immutable.persistentListOf
9+
import org.junit.Assert.assertEquals
10+
import org.junit.Assert.assertTrue
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import org.robolectric.RobolectricTestRunner
14+
15+
@RunWith(RobolectricTestRunner::class)
16+
internal class ConversationMessageContentBuilderYouTubePreviewTest {
17+
18+
@Test
19+
fun buildContentSkipsYouTubePreviewWhenDisabled() {
20+
val content = buildConversationMessageContent(
21+
message = message(
22+
text = MESSAGE_TEXT_WITH_YOUTUBE_LINK,
23+
isYouTubePreviewEnabled = false,
24+
),
25+
subjectText = null,
26+
)
27+
28+
assertTrue(content.attachments.isEmpty())
29+
}
30+
31+
@Test
32+
fun buildContentAppendsYouTubePreviewWhenEnabled() {
33+
val content = buildConversationMessageContent(
34+
message = message(
35+
text = MESSAGE_TEXT_WITH_YOUTUBE_LINK,
36+
isYouTubePreviewEnabled = true,
37+
),
38+
subjectText = null,
39+
)
40+
41+
val preview = content.attachments.single()
42+
as ConversationMessageAttachment.YouTubePreview
43+
assertEquals(YOUTUBE_VIDEO_URL, preview.sourceUrl)
44+
}
45+
46+
@Test
47+
fun buildContentSkipsYouTubePreviewWhenMessageHasImageAttachment() {
48+
val content = buildConversationMessageContent(
49+
message = message(
50+
text = MESSAGE_TEXT_WITH_YOUTUBE_LINK,
51+
isYouTubePreviewEnabled = true,
52+
parts = persistentListOf(
53+
ConversationMessagePartUiModel.Attachment.Image(
54+
text = null,
55+
contentType = "image/jpeg",
56+
contentUri = "content://example.test/image/1".toUri(),
57+
width = 320,
58+
height = 240,
59+
partId = "part-1",
60+
),
61+
),
62+
),
63+
subjectText = null,
64+
)
65+
66+
assertTrue(
67+
content.attachments.none { attachment ->
68+
attachment is ConversationMessageAttachment.YouTubePreview
69+
},
70+
)
71+
}
72+
73+
private fun message(
74+
text: String,
75+
isYouTubePreviewEnabled: Boolean,
76+
parts: ImmutableList<ConversationMessagePartUiModel> = persistentListOf(),
77+
): ConversationMessageUiModel {
78+
return ConversationMessageUiModel(
79+
messageId = "message-1",
80+
conversationId = "conversation-1",
81+
text = text,
82+
parts = parts,
83+
sentTimestamp = 0L,
84+
receivedTimestamp = 0L,
85+
displayTimestamp = 0L,
86+
status = ConversationMessageUiModel.Status.Incoming.Complete,
87+
isIncoming = true,
88+
senderDisplayName = null,
89+
senderAvatarUri = null,
90+
senderContactId = 0L,
91+
senderContactLookupKey = null,
92+
senderNormalizedDestination = null,
93+
senderParticipantId = null,
94+
selfParticipantId = null,
95+
canClusterWithPrevious = false,
96+
canClusterWithNext = false,
97+
canCopyMessageToClipboard = false,
98+
canDownloadMessage = false,
99+
canForwardMessage = false,
100+
canResendMessage = false,
101+
canSaveAttachments = false,
102+
isYouTubePreviewEnabled = isYouTubePreviewEnabled,
103+
mmsDownload = null,
104+
mmsSubject = null,
105+
protocol = ConversationMessageUiModel.Protocol.SMS,
106+
)
107+
}
108+
109+
private companion object {
110+
private const val YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
111+
private const val MESSAGE_TEXT_WITH_YOUTUBE_LINK = "Watch this: $YOUTUBE_VIDEO_URL"
112+
}
113+
}

0 commit comments

Comments
 (0)