diff --git a/README.md b/README.md index 3dfae22c..53778b0d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/design/phosphor-icons.lock.json b/design/phosphor-icons.lock.json index e47f66bc..b0b39761 100644 --- a/design/phosphor-icons.lock.json +++ b/design/phosphor-icons.lock.json @@ -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", diff --git a/docs/behavior.md b/docs/behavior.md index 823d4463..73677ee8 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -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 diff --git a/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsPreferences.kt b/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsPreferences.kt index b24aae8b..ba521e4b 100644 --- a/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsPreferences.kt +++ b/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsPreferences.kt @@ -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") @@ -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") diff --git a/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsRepository.kt b/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsRepository.kt index a345745f..b1a50cbc 100644 --- a/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsRepository.kt +++ b/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsRepository.kt @@ -144,7 +144,6 @@ private fun AccountSettings.toPreferences(): AccountSettingsPreferences = defaultSort = FilesSort.fromApiValue(sortBy), diagnosticsEnabled = diagnosticsEnabled, productAnalyticsEnabled = productAnalyticsEnabled, - supportWidgetEnabled = supportWidgetEnabled, ) private fun AccountSettingsChange.toPatch(): AccountSettingsPatch = @@ -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") diff --git a/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsState.kt b/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsState.kt index 891cb01c..03730f11 100644 --- a/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsState.kt +++ b/domain/account/src/main/kotlin/io/putdotio/android/settings/AccountSettingsState.kt @@ -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. */ @@ -56,7 +56,6 @@ public enum class AccountSettingsKey { DefaultSort, Diagnostics, ProductAnalytics, - SupportWidget, } public sealed interface AccountSettingsChange { diff --git a/domain/account/src/test/kotlin/io/putdotio/android/settings/SdkAccountSettingsRepositoryTest.kt b/domain/account/src/test/kotlin/io/putdotio/android/settings/SdkAccountSettingsRepositoryTest.kt index f9497db2..bac8d846 100644 --- a/domain/account/src/test/kotlin/io/putdotio/android/settings/SdkAccountSettingsRepositoryTest.kt +++ b/domain/account/src/test/kotlin/io/putdotio/android/settings/SdkAccountSettingsRepositoryTest.kt @@ -53,8 +53,7 @@ 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]) @@ -62,8 +61,7 @@ class SdkAccountSettingsRepositoryTest { 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 @@ -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 { @@ -381,6 +387,8 @@ class SdkAccountSettingsRepositoryTest { hideSubtitles = true, dontAutoselectSubtitles = true, useStartFrom = false, + diagnosticsEnabled = true, + productAnalyticsEnabled = false, ) } } diff --git a/mobile/src/main/kotlin/io/putdotio/android/account/MobileAccountScreen.kt b/mobile/src/main/kotlin/io/putdotio/android/account/MobileAccountScreen.kt index 07052d18..69d96a8d 100644 --- a/mobile/src/main/kotlin/io/putdotio/android/account/MobileAccountScreen.kt +++ b/mobile/src/main/kotlin/io/putdotio/android/account/MobileAccountScreen.kt @@ -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, @@ -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), diff --git a/mobile/src/main/res/drawable/ic_ph_lifebuoy.xml b/mobile/src/main/res/drawable/ic_ph_lifebuoy.xml deleted file mode 100644 index ea58adf7..00000000 --- a/mobile/src/main/res/drawable/ic_ph_lifebuoy.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index d3cd7bb0..f593fdbc 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -170,8 +170,6 @@ Send crash and playback error reports so we can fix problems. They never include file names or content. Product analytics Share which features you use so we can improve the app. - Support chat - Load in-app support chat and share account context with the support team when you open it. These choices apply to every put.io app you sign in to. Playback About diff --git a/mobile/src/test/kotlin/io/putdotio/android/MobileAccountScreenTest.kt b/mobile/src/test/kotlin/io/putdotio/android/MobileAccountScreenTest.kt index a80bb00b..4a395be4 100644 --- a/mobile/src/test/kotlin/io/putdotio/android/MobileAccountScreenTest.kt +++ b/mobile/src/test/kotlin/io/putdotio/android/MobileAccountScreenTest.kt @@ -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 @@ -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