Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ Mobile, on the emulator harness against the live API:
Choices survive player recreation; unavailable audio tracks fall back to automatic selection
- Account settings: subtitles, history, Trash, resume playback, proxy route,
and default sort order, each saved to the account and shared across devices
- Privacy controls for the support chat widget, shared across put.io apps,
with a strictly-necessary storage disclosure
- Privacy controls for diagnostics (on by default) and product analytics
(opt-in), shared across put.io apps, with a strictly-necessary storage disclosure
- About section with copyable app, Android, device, and player info
- Downloads: video saves the same HLS rendition it streams (without subtitles
for accounts that hide them), audio saves the original. A Downloads screen
Expand Down
7 changes: 0 additions & 7 deletions design/phosphor-icons.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,6 @@
"sourceSha256": "8a432fac53ae887b1726a96462aa8ea285e714b8e480261751361e4d6b2b226f",
"outputSha256": "ef55ee652c4ff22f0c61ecb65991dfe2be4486b5441f9f8774f64520af9f7d32"
},
{
"resDir": "mobile/src/main/res",
"resource": "ic_ph_lifebuoy",
"asset": "assets/regular/lifebuoy.svg",
"sourceSha256": "450ce5b7ec604f37c2d3dfc0f2972d35557121a050e83854135429376c3b95f0",
"outputSha256": "8cabc249ef22af0239f8b36bf22faa2216f4394e62618299e516b5a7d8fc6333"
},
{
"resDir": "mobile/src/main/res",
"resource": "ic_ph_shield_check",
Expand Down
10 changes: 10 additions & 0 deletions docs/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,16 @@ with what is used either way.
Tests: `PutioAuthSessionGatewayTest`, `PutioTvSessionGatewayTest`,
`MobileAccountScreenTest`, `TvAccountScreenTest`.

## Privacy controls

Mobile Settings shows two account-wide privacy controls, shared with every
put.io app: Diagnostics (`diagnostics_enabled`, on by default) and Product
analytics (`product_analytics_enabled`, opt-in and off by default). Strictly
necessary processing is disclosed beside them and has no toggle. The app reads
and writes no support-widget setting.

Tests: `SdkAccountSettingsRepositoryTest`, `MobileAccountScreenTest`.

## Recent searches

Recent searches are the account's `/config` `searchHistory` list, newest first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ private fun AccountSettingsPreferences.toggleValue(key: AccountSettingsKey): Boo
AccountSettingsKey.ResumePlayback -> resumePlayback
AccountSettingsKey.Diagnostics -> diagnosticsEnabled
AccountSettingsKey.ProductAnalytics -> productAnalyticsEnabled
AccountSettingsKey.SupportWidget -> supportWidgetEnabled
AccountSettingsKey.TunnelRoute,
AccountSettingsKey.DefaultSort,
-> error("$key is not a toggle")
Expand All @@ -40,7 +39,6 @@ private fun AccountSettingsPreferences.applyingToggle(
AccountSettingsKey.ResumePlayback -> copy(resumePlayback = change.enabled)
AccountSettingsKey.Diagnostics -> copy(diagnosticsEnabled = change.enabled)
AccountSettingsKey.ProductAnalytics -> copy(productAnalyticsEnabled = change.enabled)
AccountSettingsKey.SupportWidget -> copy(supportWidgetEnabled = change.enabled)
AccountSettingsKey.TunnelRoute,
AccountSettingsKey.DefaultSort,
-> error("${change.key} is not a toggle")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ private fun AccountSettings.toPreferences(): AccountSettingsPreferences =
defaultSort = FilesSort.fromApiValue(sortBy),
diagnosticsEnabled = diagnosticsEnabled,
productAnalyticsEnabled = productAnalyticsEnabled,
supportWidgetEnabled = supportWidgetEnabled,
)

private fun AccountSettingsChange.toPatch(): AccountSettingsPatch =
Expand All @@ -159,7 +158,6 @@ private fun AccountSettingsChange.toPatch(): AccountSettingsPatch =
AccountSettingsKey.ResumePlayback -> AccountSettingsPatch(useStartFrom = enabled)
AccountSettingsKey.Diagnostics -> AccountSettingsPatch(diagnosticsEnabled = enabled)
AccountSettingsKey.ProductAnalytics -> AccountSettingsPatch(productAnalyticsEnabled = enabled)
AccountSettingsKey.SupportWidget -> AccountSettingsPatch(supportWidgetEnabled = enabled)
AccountSettingsKey.TunnelRoute,
AccountSettingsKey.DefaultSort,
-> error("$key is not a toggle")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ public data class AccountSettingsPreferences(
// Account-wide `sort_by`: folders without their own sort use it. Null means the
// server reported a value this app does not know.
val defaultSort: FilesSort? = null,
// Privacy controls per the frontend analytics contract; the server defaults all three to true.
// Privacy controls per the frontend analytics contract: diagnostics are on by
// default, product analytics is opt-in.
val diagnosticsEnabled: Boolean = true,
val productAnalyticsEnabled: Boolean = true,
val supportWidgetEnabled: Boolean = true,
val productAnalyticsEnabled: Boolean = false,
)

/** Server route identifier. `default` is the direct Amsterdam route and what a null setting means. */
Expand Down Expand Up @@ -56,7 +56,6 @@ public enum class AccountSettingsKey {
DefaultSort,
Diagnostics,
ProductAnalytics,
SupportWidget,
}

public sealed interface AccountSettingsChange {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,15 @@ class SdkAccountSettingsRepositoryTest {
repository.save(AccountSettingsChange(AccountSettingsKey.AutoSelectSubtitles, true))
repository.save(AccountSettingsChange(AccountSettingsKey.ResumePlayback, false))
repository.save(AccountSettingsChange(AccountSettingsKey.Diagnostics, false))
repository.save(AccountSettingsChange(AccountSettingsKey.ProductAnalytics, false))
repository.save(AccountSettingsChange(AccountSettingsKey.SupportWidget, false))
repository.save(AccountSettingsChange(AccountSettingsKey.ProductAnalytics, true))

assertEquals(AccountSettingsPatch(historyEnabled = false), patches[0])
assertEquals(AccountSettingsPatch(trashEnabled = true), patches[1])
assertEquals(AccountSettingsPatch(hideSubtitles = false), patches[2])
assertEquals(AccountSettingsPatch(dontAutoselectSubtitles = false), patches[3])
assertEquals(AccountSettingsPatch(useStartFrom = false), patches[4])
assertEquals(AccountSettingsPatch(diagnosticsEnabled = false), patches[5])
assertEquals(AccountSettingsPatch(productAnalyticsEnabled = false), patches[6])
assertEquals(AccountSettingsPatch(supportWidgetEnabled = false), patches[7])
assertEquals(AccountSettingsPatch(productAnalyticsEnabled = true), patches[6])
}

@Test
Expand Down Expand Up @@ -244,25 +242,33 @@ class SdkAccountSettingsRepositoryTest {
}

@Test
fun mapsPrivacyControlsFromServerAndDefaultsThemOn() =
fun mapsPrivacyControlsFromServer() =
runBlocking {
val loaded = repository(settings = Settings).load() as AccountSettingsRepositoryResult.Success
assertTrue(loaded.value.diagnosticsEnabled)
assertTrue(loaded.value.productAnalyticsEnabled)
assertTrue(loaded.value.supportWidgetEnabled)
val optedIn = repository(
settings = Settings.copy(diagnosticsEnabled = true, productAnalyticsEnabled = true),
).load() as AccountSettingsRepositoryResult.Success
assertTrue(optedIn.value.diagnosticsEnabled)
assertTrue(optedIn.value.productAnalyticsEnabled)

val optedOut = repository(
settings = Settings.copy(
diagnosticsEnabled = false,
productAnalyticsEnabled = false,
supportWidgetEnabled = false,
),
settings = Settings.copy(diagnosticsEnabled = false, productAnalyticsEnabled = false),
).load() as AccountSettingsRepositoryResult.Success
assertFalse(optedOut.value.diagnosticsEnabled)
assertFalse(optedOut.value.productAnalyticsEnabled)
assertFalse(optedOut.value.supportWidgetEnabled)
}

@Test
fun productAnalyticsIsOptInWhileDiagnosticsStayOnByDefault() {
val defaults = AccountSettingsPreferences(
historyEnabled = true,
trashEnabled = true,
showSubtitles = true,
autoSelectSubtitles = true,
)
assertTrue(defaults.diagnosticsEnabled)
assertFalse(defaults.productAnalyticsEnabled)
}

@Test
fun mapsDefaultSortFromServerAndPatchesTheExactApiValue() =
runBlocking {
Expand Down Expand Up @@ -381,6 +387,8 @@ class SdkAccountSettingsRepositoryTest {
hideSubtitles = true,
dontAutoselectSubtitles = true,
useStartFrom = false,
diagnosticsEnabled = true,
productAnalyticsEnabled = false,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ private fun LazyListScope.accountSettingsItems(
}

// Purpose-based opt-outs per the frontend analytics contract. Strictly necessary
// processing is disclosed, never toggled; the three account keys are cross-client.
// processing is disclosed, never toggled; both account keys are cross-client.
private fun LazyListScope.privacyControlsItems(
preferences: AccountSettingsPreferences,
mutation: AccountSettingsMutation,
Expand Down Expand Up @@ -469,18 +469,6 @@ private fun LazyListScope.privacyControlsItems(
onChange = onChange,
)
}
item(key = AccountSettingsKey.SupportWidget) {
MobileAccountSettingRow(
title = R.string.mobile_settings_support_widget,
description = R.string.mobile_settings_support_widget_description,
icon = R.drawable.ic_ph_lifebuoy,
checked = preferences.supportWidgetEnabled,
key = AccountSettingsKey.SupportWidget,
mutation = mutation,
onRetry = onRetryChange,
onChange = onChange,
)
}
item(key = PRIVACY_CONTROLS_NOTE_KEY) {
Text(
text = stringResource(R.string.mobile_settings_privacy_controls_note),
Expand Down
10 changes: 0 additions & 10 deletions mobile/src/main/res/drawable/ic_ph_lifebuoy.xml

This file was deleted.

2 changes: 0 additions & 2 deletions mobile/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,6 @@
<string name="mobile_settings_diagnostics_description">Send crash and playback error reports so we can fix problems. They never include file names or content.</string>
<string name="mobile_settings_product_analytics">Product analytics</string>
<string name="mobile_settings_product_analytics_description">Share which features you use so we can improve the app.</string>
<string name="mobile_settings_support_widget">Support chat</string>
<string name="mobile_settings_support_widget_description">Load in-app support chat and share account context with the support team when you open it.</string>
<string name="mobile_settings_privacy_controls_note">These choices apply to every put.io app you sign in to.</string>
<string name="mobile_settings_section_playback">Playback</string>
<string name="mobile_settings_section_about">About</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.text.format.Formatter
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
Expand Down Expand Up @@ -242,21 +243,30 @@ class MobileAccountScreenTest {
SemanticsMatcher.keyNotDefined(SemanticsProperties.ToggleableState),
)

for ((label, key) in listOf(
"Diagnostics" to AccountSettingsKey.Diagnostics,
"Product analytics" to AccountSettingsKey.ProductAnalytics,
"Support chat" to AccountSettingsKey.SupportWidget,
)) {
compose.onNodeWithTag(MOBILE_ACCOUNT_LIST_TAG).performScrollToNode(hasText(label))
compose.onNodeWithText(label).assertIsOn().performClick()
compose.runOnIdle {
assertEquals(
AccountSettingsEvent.ChangeRequested(AccountSettingsChange(key, enabled = false)),
events.last(),
)
}
compose.onNodeWithTag(MOBILE_ACCOUNT_LIST_TAG).performScrollToNode(hasText("Diagnostics"))
compose.onNodeWithText("Diagnostics").assertIsOn().performClick()
compose.runOnIdle {
assertEquals(
AccountSettingsEvent.ChangeRequested(
AccountSettingsChange(AccountSettingsKey.Diagnostics, enabled = false),
),
events.last(),
)
}
compose.runOnIdle { assertEquals(3, events.size) }
compose.onNodeWithTag(MOBILE_ACCOUNT_LIST_TAG).performScrollToNode(hasText("Product analytics"))
compose.onNodeWithText("Product analytics").assertIsOff().performClick()
compose.runOnIdle {
assertEquals(
AccountSettingsEvent.ChangeRequested(
AccountSettingsChange(AccountSettingsKey.ProductAnalytics, enabled = true),
),
events.last(),
)
}
compose.onNodeWithTag(MOBILE_ACCOUNT_LIST_TAG)
.performScrollToNode(hasText("These choices apply to every put.io app you sign in to."))
compose.onAllNodesWithText("Support chat").assertCountEquals(0)
compose.runOnIdle { assertEquals(2, events.size) }
}

@Test
Expand Down