diff --git a/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvSafeAreaProofTest.kt b/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvSafeAreaProofTest.kt index 14f45dd0..8c95728d 100644 --- a/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvSafeAreaProofTest.kt +++ b/app/src/androidTestTv/kotlin/io/putdotio/android/tv/TvSafeAreaProofTest.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule @@ -26,10 +27,13 @@ import androidx.compose.ui.test.pressKey import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.tv.material3.MaterialTheme +import io.putdotio.android.account.InactiveAccountNotice import io.putdotio.android.design.PutioDesignTokens import io.putdotio.android.design.putioTvDarkColorScheme import io.putdotio.android.tv.auth.TvAccount import java.io.File +import java.time.LocalDate +import java.time.ZoneId import java.util.UUID import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue @@ -85,6 +89,32 @@ class TvSafeAreaProofTest { assertContentInsideSafeArea() } + @Test + fun inactiveAccountNoticeStaysInsideTheSafeArea() { + val zone = ZoneId.systemDefault() + val deletion = LocalDate.now(zone).plusDays(14).atTime(12, 0).atZone(zone).toInstant() + val account = TvAccount( + userId = 1, + username = "proof", + email = "proof@example.com", + inactiveNotice = InactiveAccountNotice.Deactivated(deletion), + ) + compose.setContent { + MaterialTheme(colorScheme = putioTvDarkColorScheme()) { + Box(Modifier.fillMaxSize().drawWithContent { drawContent(); outlineSafeArea(size) }) { + TvShell(account = account, onSignOut = {}) + } + } + } + compose.waitForIdle() + compose.onNodeWithText("Your files are still here, but they are scheduled to be deleted in 14 days.") + .assertExists() + compose.onNodeWithText("Your files will show up here.").assertIsFocused() + val label = compose.onRoot().fetchSemanticsNode().boundsInRoot.let { "${it.width.toInt()}x${it.height.toInt()}" } + screenshot("inactive-$label") + assertContentInsideSafeArea() + } + private fun androidx.compose.ui.graphics.drawscope.DrawScope.outlineSafeArea(viewport: Size) { val safe = safeArea(Rect(Offset.Zero, viewport)) drawRect(Color.Red, topLeft = safe.topLeft, size = safe.size, style = Stroke(width = 2f)) diff --git a/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt b/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt new file mode 100644 index 00000000..9c89c9a3 --- /dev/null +++ b/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt @@ -0,0 +1,71 @@ +package io.putdotio.android.account + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import io.putdotio.android.R +import io.putdotio.android.parsePutioTimestamp +import io.putdotio.sdk.account.AccountInfo +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import kotlin.math.abs + +/** + * The persistent notice an inactive account shows above every signed-in screen, with + * putio-web's states (`AccountStatusNotification`): a family plan member is told the plan + * expired, anyone else that the account is deactivated and when the files go. + * Google Play's payments policy rules out web's billing and family links and its calls to + * pay, renew or subscribe, so the notice states facts only and has no action. + * Web's active-account payment warnings and `stranger` notice are not ported. + */ +sealed interface InactiveAccountNotice { + /** [filesDeletedAt] is null when put.io sent no usable deletion date; the notice then has no message. */ + data class Deactivated( + val filesDeletedAt: Instant?, + ) : InactiveAccountNotice + + data object FamilyPlanExpired : InactiveAccountNotice +} + +internal fun AccountInfo.inactiveAccountNotice(): InactiveAccountNotice? = + when { + accountStatus != INACTIVE_STATUS -> null + isSubAccount -> InactiveAccountNotice.FamilyPlanExpired + else -> InactiveAccountNotice.Deactivated(filesWillBeDeletedAt?.let(::parsePutioTimestamp)) + } + +/** + * Whole calendar days between today and the deletion day in [zone], as web's `daysDiffFromNow` + * counts them: absolute, so a date already past still reads as a distance. + */ +fun InactiveAccountNotice.Deactivated.daysUntilFilesDeleted(now: Instant, zone: ZoneId): Long? = + filesDeletedAt?.let { abs(ChronoUnit.DAYS.between(now.atZone(zone).toLocalDate(), it.atZone(zone).toLocalDate())) } + +/** The notice's words; [message] is null where web shows none. */ +data class InactiveAccountNoticeText( + val title: String, + val message: String?, +) + +@Composable +fun InactiveAccountNotice.text( + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), +): InactiveAccountNoticeText = + when (this) { + is InactiveAccountNotice.Deactivated -> + InactiveAccountNoticeText( + title = stringResource(R.string.account_inactive_title), + message = daysUntilFilesDeleted(now, zone)?.toInt()?.let { days -> + pluralStringResource(R.plurals.account_inactive_files_deletion, days, days) + }, + ) + InactiveAccountNotice.FamilyPlanExpired -> + InactiveAccountNoticeText( + title = stringResource(R.string.account_family_expired_title), + message = null, + ) + } + +private const val INACTIVE_STATUS = "inactive" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9371458..f8938f0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,21 @@ put.io - + + + Your account has been deactivated 😢 + + + Your files are still here, but they are scheduled to be deleted in %d day. + Your files are still here, but they are scheduled to be deleted in %d days. + + + Your family plan is no longer active. + diff --git a/app/src/mobile/kotlin/io/putdotio/android/MobileShell.kt b/app/src/mobile/kotlin/io/putdotio/android/MobileShell.kt index c7de698b..0843d785 100644 --- a/app/src/mobile/kotlin/io/putdotio/android/MobileShell.kt +++ b/app/src/mobile/kotlin/io/putdotio/android/MobileShell.kt @@ -40,6 +40,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import io.putdotio.android.account.InactiveAccountNotice +import io.putdotio.android.account.MobileInactiveAccountNotice import io.putdotio.android.auth.MobileAccount import io.putdotio.android.auth.MobileAuthSessionId import io.putdotio.android.downloads.DownloadsController @@ -346,6 +348,7 @@ internal fun MobileShell( playbackPlayerFactory = playbackPlayerFactory, onFilesEvent = { onFilesEvent(it) }, onFilesBack = onFilesBack, + inactiveNotice = account.inactiveNotice, ) { contentModifier -> MobileNavHost( transferDraft = transferDraft, @@ -464,6 +467,7 @@ private fun MobileChrome( playbackPlayerFactory: MobilePlayerFactory, onFilesEvent: (FilesBrowserEvent) -> Boolean, onFilesBack: () -> Unit, + inactiveNotice: InactiveAccountNotice?, content: @Composable (Modifier) -> Unit, ) { Row(modifier = Modifier.fillMaxSize()) { @@ -484,16 +488,25 @@ private fun MobileChrome( topBar = { if (visible) { val route = navController.currentBackStackEntryAsState().value?.destination?.route - MobileTopBar( - openNavigation = openNavigation, - destination = selectedDestination, - isTrash = route == MOBILE_TRASH_ROUTE, - isDownloads = route == MOBILE_DOWNLOADS_ROUTE, - onTrashBack = { navController.popBackStack() }, - filesState = filesState, - onFilesBack = onFilesBack, - onFilesEvent = onFilesEvent, - ) + Column { + MobileTopBar( + openNavigation = openNavigation, + destination = selectedDestination, + isTrash = route == MOBILE_TRASH_ROUTE, + isDownloads = route == MOBILE_DOWNLOADS_ROUTE, + onTrashBack = { navController.popBackStack() }, + filesState = filesState, + onFilesBack = onFilesBack, + onFilesEvent = onFilesEvent, + ) + // Every signed-in screen, like web's app layout; the top bar owns the status bar inset. + inactiveNotice?.let { notice -> + MobileInactiveAccountNotice( + notice, + Modifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)), + ) + } + } } }, bottomBar = { diff --git a/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt b/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt new file mode 100644 index 00000000..18a9b982 --- /dev/null +++ b/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt @@ -0,0 +1,37 @@ +package io.putdotio.android.account + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp + +internal const val MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG = "mobile-inactive-account-notice" + +/** The inactive-account banner under the top bar; it only informs, so nothing in it is clickable. */ +@Composable +internal fun MobileInactiveAccountNotice( + notice: InactiveAccountNotice, + modifier: Modifier = Modifier, +) { + val text = notice.text() + Surface( + modifier = modifier + .fillMaxWidth() + .testTag(MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(text = text.title, style = MaterialTheme.typography.titleSmall) + text.message?.let { + Text(text = it, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp)) + } + } + } +} diff --git a/app/src/mobile/kotlin/io/putdotio/android/auth/MobileAuthController.kt b/app/src/mobile/kotlin/io/putdotio/android/auth/MobileAuthController.kt index 79da8871..22383980 100644 --- a/app/src/mobile/kotlin/io/putdotio/android/auth/MobileAuthController.kt +++ b/app/src/mobile/kotlin/io/putdotio/android/auth/MobileAuthController.kt @@ -1,6 +1,7 @@ package io.putdotio.android.auth import io.putdotio.android.AccountStorage +import io.putdotio.android.account.InactiveAccountNotice import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -17,6 +18,7 @@ data class MobileAccount( val historyEnabled: Boolean = false, val avatarUrl: String? = null, val storage: AccountStorage = AccountStorage(), + val inactiveNotice: InactiveAccountNotice? = null, ) @JvmInline diff --git a/app/src/mobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGateway.kt b/app/src/mobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGateway.kt index 25d776a8..e9a51b2e 100644 --- a/app/src/mobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGateway.kt +++ b/app/src/mobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGateway.kt @@ -1,5 +1,6 @@ package io.putdotio.android.auth +import io.putdotio.android.account.inactiveAccountNotice import io.putdotio.android.toAccountStorage import io.putdotio.sdk.PutioClient import io.putdotio.sdk.account.AccountInfo @@ -122,4 +123,5 @@ private fun AccountInfo.toMobileAccount(): MobileAccount = historyEnabled = settings.historyEnabled, avatarUrl = avatarUrl, storage = toAccountStorage(), + inactiveNotice = inactiveAccountNotice(), ) diff --git a/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt b/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt new file mode 100644 index 00000000..4f348777 --- /dev/null +++ b/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt @@ -0,0 +1,73 @@ +package io.putdotio.android.account + +import io.putdotio.sdk.account.AccountDisk +import io.putdotio.sdk.account.AccountInfo +import io.putdotio.sdk.account.AccountSettings +import java.time.Instant +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class InactiveAccountNoticeTest { + @Test + fun `active and stranger accounts show no notice`() { + assertNull(account(status = "active", deletesAt = "2026-10-15T08:00:00").inactiveAccountNotice()) + assertNull(account(status = "stranger").inactiveAccountNotice()) + } + + @Test + fun `an inactive account carries its deletion date, read as UTC when put_io sends no zone`() { + assertEquals( + InactiveAccountNotice.Deactivated(Instant.parse("2026-10-15T08:00:00Z")), + account(status = "inactive", deletesAt = "2026-10-15T08:00:00").inactiveAccountNotice(), + ) + } + + @Test + fun `an inactive account without a usable deletion date still shows the notice`() { + assertEquals(InactiveAccountNotice.Deactivated(null), account(status = "inactive").inactiveAccountNotice()) + assertEquals( + InactiveAccountNotice.Deactivated(null), + account(status = "inactive", deletesAt = "soon").inactiveAccountNotice(), + ) + } + + @Test + fun `an inactive family plan member gets the family plan notice`() { + assertEquals( + InactiveAccountNotice.FamilyPlanExpired, + account(status = "inactive", deletesAt = "2026-10-15T08:00:00", subAccount = true).inactiveAccountNotice(), + ) + } + + @Test + fun `deletion days count calendar days in the viewer's zone, as web does`() { + val notice = InactiveAccountNotice.Deactivated(Instant.parse("2026-10-15T01:00:00Z")) + val now = Instant.parse("2026-10-01T22:00:00Z") + + assertEquals(14L, notice.daysUntilFilesDeleted(now, ZoneOffset.UTC)) + // 2026-10-02 01:00 to 2026-10-15 04:00 at +03:00. + assertEquals(13L, notice.daysUntilFilesDeleted(now, ZoneOffset.ofHours(3))) + assertEquals(0L, notice.daysUntilFilesDeleted(Instant.parse("2026-10-15T23:00:00Z"), ZoneOffset.UTC)) + assertEquals(2L, notice.daysUntilFilesDeleted(Instant.parse("2026-10-17T00:00:00Z"), ZoneOffset.UTC)) + assertNull(InactiveAccountNotice.Deactivated(null).daysUntilFilesDeleted(now, ZoneOffset.UTC)) + } + + private fun account( + status: String, + deletesAt: String? = null, + subAccount: Boolean = false, + ) = AccountInfo( + userId = 1, + username = "user", + mail = "user@example.com", + avatarUrl = "", + disk = AccountDisk(available = 1, size = 2, used = 1), + settings = AccountSettings(sortBy = "NAME_ASC"), + accountStatus = status, + accountActive = status == "active", + filesWillBeDeletedAt = deletesAt, + isSubAccount = subAccount, + ) +} diff --git a/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt b/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt new file mode 100644 index 00000000..c4a4c039 --- /dev/null +++ b/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt @@ -0,0 +1,86 @@ +package io.putdotio.android + +import androidx.activity.ComponentActivity +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.account.InactiveAccountNotice +import io.putdotio.android.account.MobileInactiveAccountNotice +import io.putdotio.android.design.PutioTheme +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileInactiveAccountNoticeTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun deactivatedAccountShowsWebCopyWithTheDeletionCountdown() { + compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(deletionIn(14))) } } + + compose.onNodeWithText("Your account has been deactivated 😢").assertIsDisplayed() + compose.onNodeWithText("Your files are still here, but they are scheduled to be deleted in 14 days.") + .assertIsDisplayed() + assertNoActionOrPaymentWording() + } + + @Test + fun deletionTomorrowReadsInTheSingular() { + compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(deletionIn(1))) } } + + compose.onNodeWithText("Your files are still here, but they are scheduled to be deleted in 1 day.") + .assertIsDisplayed() + } + + @Test + fun withoutADeletionDateOnlyTheTitleShows() { + compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(null)) } } + + compose.onNodeWithText("Your account has been deactivated 😢").assertIsDisplayed() + compose.onNodeWithText("Your files are still here", substring = true).assertDoesNotExist() + assertNoActionOrPaymentWording() + } + + @Test + fun familyPlanMemberIsToldThePlanExpired() { + compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.FamilyPlanExpired) } } + + compose.onNodeWithText("Your family plan is no longer active.").assertIsDisplayed() + assertNoActionOrPaymentWording() + } + + /** Google Play's payments policy: no link out to billing and no call to pay, renew or subscribe. */ + private fun assertNoActionOrPaymentWording() { + compose.onAllNodes(hasClickAction()).assertCountEquals(0) + compose.onAllNodesWithText("app.put.io", substring = true).assertCountEquals(0) + val words = compose.onRoot(useUnmergedTree = true).fetchSemanticsNode().let { root -> + generateSequence(listOf(root)) { nodes -> nodes.flatMap { it.children }.ifEmpty { null } } + .flatten() + .flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() } + .joinToString(" ") + } + assertEquals(emptyList(), PaymentWords.findAll(words).map { it.value }.toList()) + } + + private fun deletionIn(days: Long) = ZoneId.systemDefault().let { zone -> + LocalDate.now(zone).plusDays(days).atTime(12, 0).atZone(zone).toInstant() + } + + private companion object { + val PaymentWords = Regex("""\b(pay\w*|renew\w*|subscri\w*|billing|keep a good thing going)""", RegexOption.IGNORE_CASE) + } +} diff --git a/app/src/testMobile/kotlin/io/putdotio/android/MobileShellTest.kt b/app/src/testMobile/kotlin/io/putdotio/android/MobileShellTest.kt index 532cc8f8..f3b549ec 100644 --- a/app/src/testMobile/kotlin/io/putdotio/android/MobileShellTest.kt +++ b/app/src/testMobile/kotlin/io/putdotio/android/MobileShellTest.kt @@ -41,6 +41,8 @@ import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.Player as Media3Player import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.putdotio.android.account.InactiveAccountNotice +import io.putdotio.android.account.MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG import io.putdotio.android.auth.MobileAccount import io.putdotio.android.auth.MobileAuthSessionId import io.putdotio.android.auth.MobileSignedOutReason @@ -1081,6 +1083,35 @@ class MobileShellTest { } } + @Test + fun inactiveAccountNoticeStaysAboveEveryDestinationAndActiveAccountsShowNone() { + var account by mutableStateOf(Account.copy(inactiveNotice = InactiveAccountNotice.Deactivated(null))) + compose.setContent { + PutioTheme { + MobileShell( + playbackPlayerFactory = NoAudioSessionFactory, + filesState = emptyFilesState(), + accountSettingsState = readyAccountSettingsState(), + appConfigState = readyAndroidAppConfigState(), + account = account, + playbackRepository = ConversionRepository, + sessionId = Session, + onFilesEvent = { true }, + onAccountSettingsEvent = {}, + onPlaybackAuthenticationRequired = {}, + onSignOut = {}, + ) + } + } + compose.onNodeWithText("Your account has been deactivated 😢").assertIsDisplayed() + + compose.onNode(hasText("Account") and hasAnyAncestor(hasTestTag(MOBILE_NAV_BAR_TAG))).performClick() + compose.onNodeWithTag(MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG).assertIsDisplayed() + + account = Account + compose.onNodeWithTag(MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG).assertDoesNotExist() + } + @Test fun acceptedDefaultSortChangeInvalidatesSortOrderOnceAndNotOnFirstLoad() { val events = mutableListOf() diff --git a/app/src/testMobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGatewayTest.kt b/app/src/testMobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGatewayTest.kt index ecceaaa1..91905465 100644 --- a/app/src/testMobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGatewayTest.kt +++ b/app/src/testMobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGatewayTest.kt @@ -1,6 +1,7 @@ package io.putdotio.android.auth import io.putdotio.android.AccountStorage +import io.putdotio.android.account.InactiveAccountNotice import io.putdotio.sdk.account.AccountDisk import io.putdotio.sdk.account.AccountInfo import io.putdotio.sdk.account.AccountSettings @@ -35,6 +36,17 @@ class PutioAuthSessionGatewayTest { ) } + @Test + fun `an inactive family plan member carries the family notice`() = runBlocking { + val boundary = FakePutioSdkAuthBoundary(accountStatus = "inactive", subAccount = true) + val result = PutioAuthSessionGateway(boundary).validateSession() + + assertEquals( + InactiveAccountNotice.FamilyPlanExpired, + (result as SessionValidationResult.Valid).account.inactiveNotice, + ) + } + @Test fun `false validation rejects without loading account`() = runBlocking { val boundary = FakePutioSdkAuthBoundary(validateResult = false) @@ -70,6 +82,8 @@ class PutioAuthSessionGatewayTest { private class FakePutioSdkAuthBoundary( private val validateResult: Boolean = true, private val validationFailure: PutioOperationException? = null, + private val accountStatus: String = "active", + private val subAccount: Boolean = false, ) : PutioSdkAuthBoundary { val calls = mutableListOf() @@ -87,7 +101,7 @@ class PutioAuthSessionGatewayTest { override suspend fun getAccountInfo(): AccountInfo { calls += "account" - return accountInfo() + return accountInfo().copy(accountStatus = accountStatus, isSubAccount = subAccount) } } diff --git a/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt b/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt index 304e939e..4de3537b 100644 --- a/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt +++ b/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt @@ -1,10 +1,12 @@ package io.putdotio.android +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performKeyInput @@ -63,6 +65,7 @@ import io.putdotio.android.trash.TrashContent import io.putdotio.android.trash.TrashState import io.putdotio.sdk.errors.PutioConfigurationException import io.putdotio.sdk.files.PutioFileType +import io.putdotio.android.account.InactiveAccountNotice import io.putdotio.android.tv.TvShell import io.putdotio.android.tv.auth.TvAccount import io.putdotio.android.tv.auth.TvLinkPhase @@ -136,6 +139,51 @@ class TvShellTest { assertEquals(1, requests) } + @Test + fun inactiveAccountNoticeShowsAboveEveryPaneWithoutTakingFocusOrBack() { + val zone = java.time.ZoneId.systemDefault() + val deletion = java.time.LocalDate.now(zone).plusDays(14).atTime(12, 0).atZone(zone).toInstant() + compose.setContent { + MaterialTheme(colorScheme = putioTvDarkColorScheme()) { + TvShell( + account = TvAccount( + userId = 1, + username = "user", + email = "user@example.com", + inactiveNotice = InactiveAccountNotice.Deactivated(deletion), + ), + onSignOut = {}, + ) + } + } + compose.onNodeWithText("Your account has been deactivated 😢").assertIsDisplayed() + compose.onNodeWithText("Your files are still here, but they are scheduled to be deleted in 14 days.") + .assertIsDisplayed() + compose.onAllNodesWithText("app.put.io", substring = true).assertCountEquals(0) + compose.onNodeWithText("Keep a good thing going!").assertDoesNotExist() + + compose.onNodeWithText("Your files will show up here.").assertIsFocused().performKeyInput { + pressKey(Key.DirectionUp) + } + compose.onNodeWithText("Your files will show up here.").assertIsFocused().performKeyInput { + pressKey(Key.DirectionLeft) + } + compose.onNode(hasText("Files") and hasClickAction()).assertIsFocused().performKeyInput { + pressKey(Key.DirectionDown) + pressKey(Key.DirectionDown) + pressKey(Key.DirectionDown) + keyDown(Key.DirectionCenter) + keyUp(Key.DirectionCenter) + } + compose.onNodeWithText("Sign out").assertIsFocused() + compose.onNodeWithText("Your account has been deactivated 😢").assertIsDisplayed() + + compose.runOnUiThread { compose.activity.onBackPressedDispatcher.onBackPressed() } + compose.waitForIdle() + compose.onNodeWithText("Your files will show up here.").assertIsFocused() + compose.onNodeWithText("Your account has been deactivated 😢").assertIsDisplayed() + } + @Test fun returningToFilesLandsOnTheRowThatHeldFocus() { val memory = mutableMapOf() diff --git a/app/src/testTv/kotlin/io/putdotio/android/tv/auth/PutioTvSessionGatewayTest.kt b/app/src/testTv/kotlin/io/putdotio/android/tv/auth/PutioTvSessionGatewayTest.kt index 70fa8ce0..a320d671 100644 --- a/app/src/testTv/kotlin/io/putdotio/android/tv/auth/PutioTvSessionGatewayTest.kt +++ b/app/src/testTv/kotlin/io/putdotio/android/tv/auth/PutioTvSessionGatewayTest.kt @@ -1,6 +1,7 @@ package io.putdotio.android.tv.auth import io.putdotio.android.AccountStorage +import io.putdotio.android.account.InactiveAccountNotice import io.putdotio.sdk.account.AccountDisk import io.putdotio.sdk.account.AccountInfo import io.putdotio.sdk.account.AccountSettings @@ -18,6 +19,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.io.IOException +import java.time.Instant class PutioTvSessionGatewayTest { @Test @@ -41,6 +43,16 @@ class PutioTvSessionGatewayTest { ) } + @Test + fun `an inactive account carries its notice`() = runBlocking { + val result = PutioTvSessionGateway(FakeBoundary(accountStatus = "inactive")).validateSession() + + assertEquals( + InactiveAccountNotice.Deactivated(Instant.parse("2026-10-15T08:00:00Z")), + (result as TvSessionValidation.Valid).account.inactiveNotice, + ) + } + @Test fun `a false verdict rejects without loading the account`() = runBlocking { val boundary = FakeBoundary(validateResult = false) @@ -73,6 +85,7 @@ class PutioTvSessionGatewayTest { private class FakeBoundary( private val validateResult: Boolean = true, private val validationFailure: PutioException? = null, + private val accountStatus: String = "active", ) : TvSdkBoundary { val calls = mutableListOf() @@ -97,7 +110,8 @@ class PutioTvSessionGatewayTest { avatarUrl = "https://example.com/avatar.png", disk = AccountDisk(available = 1, size = 2, used = 1), settings = AccountSettings(sortBy = "NAME_ASC", showOptimisticUsage = true), - accountStatus = "active", + accountStatus = accountStatus, + filesWillBeDeletedAt = "2026-10-15T08:00:00".takeIf { accountStatus == "inactive" }, ) } } diff --git a/app/src/tv/kotlin/io/putdotio/android/tv/TvShell.kt b/app/src/tv/kotlin/io/putdotio/android/tv/TvShell.kt index f6fdec22..a310342e 100644 --- a/app/src/tv/kotlin/io/putdotio/android/tv/TvShell.kt +++ b/app/src/tv/kotlin/io/putdotio/android/tv/TvShell.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -35,6 +36,7 @@ import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text import io.putdotio.android.R import io.putdotio.android.design.PutioDesignTokens +import io.putdotio.android.tv.account.TvInactiveAccountNotice import io.putdotio.android.tv.auth.TvAccount /** @@ -49,6 +51,8 @@ import io.putdotio.android.tv.auth.TvAccount * returns to Files, and Files takes [onFilesBack] or, at its root, falls through to the * system and leaves the app. * + * An inactive account's notice sits above every pane and takes no focus. + * * The background reaches the screen edges; the drawer and pane sit inside the * overscan safe area ([tvOverscanPadding]), and the pane adds one `space.sm` * step (16dp) from the drawer and the safe edges. @@ -135,16 +139,21 @@ internal fun TvShell( } }, ) { - Box( + Column( modifier = Modifier .fillMaxSize() .padding(PANE_INSET), ) { - when (destination) { - TvDestination.Files -> filesPane(paneFocus) - TvDestination.Search -> searchPane(paneFocus) - TvDestination.History -> historyPane(paneFocus) - TvDestination.Account -> accountPane(paneFocus) + account.inactiveNotice?.let { notice -> + TvInactiveAccountNotice(notice, Modifier.padding(bottom = PANE_INSET)) + } + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + when (destination) { + TvDestination.Files -> filesPane(paneFocus) + TvDestination.Search -> searchPane(paneFocus) + TvDestination.History -> historyPane(paneFocus) + TvDestination.Account -> accountPane(paneFocus) + } } } } diff --git a/app/src/tv/kotlin/io/putdotio/android/tv/account/TvInactiveAccountNotice.kt b/app/src/tv/kotlin/io/putdotio/android/tv/account/TvInactiveAccountNotice.kt new file mode 100644 index 00000000..fd7093b7 --- /dev/null +++ b/app/src/tv/kotlin/io/putdotio/android/tv/account/TvInactiveAccountNotice.kt @@ -0,0 +1,43 @@ +package io.putdotio.android.tv.account + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import io.putdotio.android.account.InactiveAccountNotice +import io.putdotio.android.account.text + +/** The inactive-account banner above every pane; plain text, so nothing here takes D-pad focus. */ +@Composable +internal fun TvInactiveAccountNotice( + notice: InactiveAccountNotice, + modifier: Modifier = Modifier, +) { + val text = notice.text() + Column( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(16.dp)) + .padding(horizontal = 24.dp, vertical = 16.dp), + ) { + Text( + text = text.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + text.message?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + } +} diff --git a/app/src/tv/kotlin/io/putdotio/android/tv/auth/TvSessionGateway.kt b/app/src/tv/kotlin/io/putdotio/android/tv/auth/TvSessionGateway.kt index 08972a7a..081d07af 100644 --- a/app/src/tv/kotlin/io/putdotio/android/tv/auth/TvSessionGateway.kt +++ b/app/src/tv/kotlin/io/putdotio/android/tv/auth/TvSessionGateway.kt @@ -1,6 +1,8 @@ package io.putdotio.android.tv.auth import io.putdotio.android.AccountStorage +import io.putdotio.android.account.InactiveAccountNotice +import io.putdotio.android.account.inactiveAccountNotice import io.putdotio.android.auth.AccessToken import io.putdotio.android.auth.isAuthoritativeAuthRejection import io.putdotio.android.toAccountStorage @@ -21,6 +23,7 @@ data class TvAccount( /** The account's `history_enabled` setting; History is a disabled pane without it. */ val historyEnabled: Boolean = false, val avatarUrl: String? = null, + val inactiveNotice: InactiveAccountNotice? = null, ) internal sealed interface TvSessionValidation { @@ -113,6 +116,7 @@ internal fun AccountInfo.toTvAccount(): TvAccount = storage = toAccountStorage(), historyEnabled = settings.historyEnabled, avatarUrl = avatarUrl.takeIf { it.isNotBlank() }, + inactiveNotice = inactiveAccountNotice(), ) enum class TvLinkFailure { diff --git a/docs/behavior.md b/docs/behavior.md index 87553a2e..460c734a 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -75,6 +75,28 @@ has no buffer setting and always buffers as tv-native's default `medium` (see Tests: `TvAuthControllerTest`, `AsyncStorageLegacyTvSessionTest` (a fixture AsyncStorage database), `TvNativeConfigMigrationTest` (fixture `/config` blobs). +## Inactive account + +An account whose `account_status` is `inactive` shows a persistent notice on +every signed-in screen, with putio-web's states (`AccountStatusNotification`). +A family plan member (`is_sub_account`) reads Your family plan is no longer +active. Anyone else reads Your account has been deactivated 😢 and the days +until `files_will_be_deleted_at` (whole calendar days in the device's zone, as +web counts them; no message without a date). Unlike web, the count uses +Android plurals, so one day reads "1 day". Web's notices for active accounts +(payment warnings) and `stranger` accounts are not ported, so those show +nothing. The status is read with the account at sign-in and session restore. + +Google Play's payments policy rules out web's billing and family links and its +calls to pay, renew or subscribe, so the notice has no action on either surface: +facts only, taken from web's copy (`strings.xml` cites each source key). Mobile +puts it under the top bar with nothing clickable; TV puts it above the pane as +plain text with no focus target, so D-pad entry into the pane is unchanged. + +Tests: `InactiveAccountNoticeTest`, `PutioAuthSessionGatewayTest`, +`PutioTvSessionGatewayTest`, `MobileInactiveAccountNoticeTest`, +`MobileShellTest`, `TvShellTest`; on device, `TvSafeAreaProofTest`. + ## Shared-with-me items Friends' shared files (`is_shared`) and the shared folders (`SHARED_ROOT`, diff --git a/docs/harness.md b/docs/harness.md index 159b82d2..525cedf0 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -555,7 +555,8 @@ Fire TV (6233) needs the physical device set from #51. `TvSafeAreaProofTest` (TV instrumentation, synthetic account, no API calls) mounts the signed-in shell with placeholder panes at the emulator's current display, outlines the safe edge in red, screenshots the collapsed and expanded -drawer, and fails if any label or focus target leaves the safe area. It needs +drawer and the shell with an inactive account's notice, and fails if any label +or focus target leaves the safe area. It needs no sign-in, so the existing `putio-tv` session survives: ```bash