From 99136628be1ce57b8566f89a5039c89ada9c6427 Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 1 Oct 2026 00:56:37 +0300 Subject: [PATCH 1/3] feat(account): warn when the account is inactive and files are scheduled for deletion Maps account_status, is_sub_account and files_will_be_deleted_at to web's AccountStatusNotification states and copy. Mobile shows the notice under the top bar with a link to app.put.io; TV shows it above every pane with the address as text. --- .../android/tv/TvSafeAreaProofTest.kt | 30 +++++++ .../android/account/InactiveAccountNotice.kt | 84 +++++++++++++++++ app/src/main/res/values/strings.xml | 12 ++- .../kotlin/io/putdotio/android/MobileShell.kt | 33 ++++--- .../account/MobileInactiveAccountNotice.kt | 71 +++++++++++++++ .../android/auth/MobileAuthController.kt | 2 + .../android/auth/PutioAuthSessionGateway.kt | 2 + .../account/InactiveAccountNoticeTest.kt | 75 ++++++++++++++++ .../MobileInactiveAccountNoticeTest.kt | 90 +++++++++++++++++++ .../io/putdotio/android/MobileShellTest.kt | 31 +++++++ .../auth/PutioAuthSessionGatewayTest.kt | 16 +++- .../kotlin/io/putdotio/android/TvShellTest.kt | 41 +++++++++ .../tv/auth/PutioTvSessionGatewayTest.kt | 16 +++- .../kotlin/io/putdotio/android/tv/TvShell.kt | 21 +++-- .../tv/account/TvInactiveAccountNotice.kt | 63 +++++++++++++ .../android/tv/auth/TvSessionGateway.kt | 4 + docs/behavior.md | 23 +++++ docs/harness.md | 3 +- 18 files changed, 597 insertions(+), 20 deletions(-) create mode 100644 app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt create mode 100644 app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt create mode 100644 app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt create mode 100644 app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt create mode 100644 app/src/tv/kotlin/io/putdotio/android/tv/account/TvInactiveAccountNotice.kt 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..a3fe3600 --- /dev/null +++ b/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt @@ -0,0 +1,84 @@ +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 owner + * has to pay, anyone else that the account is deactivated and when the files go. + * Web's active-account payment warnings and `stranger` notice are not ported. + */ +sealed interface InactiveAccountNotice { + val renewUrl: String + + /** [filesDeletedAt] is null when put.io sent no usable deletion date; the notice then has no message. */ + data class Deactivated( + val filesDeletedAt: Instant?, + ) : InactiveAccountNotice { + override val renewUrl: String get() = BILLING_URL + } + + data object FamilyPlanExpired : InactiveAccountNotice { + override val renewUrl: String get() = FAMILY_URL + } +} + +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?, + val action: 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) + }, + action = stringResource(R.string.account_inactive_renew), + ) + InactiveAccountNotice.FamilyPlanExpired -> + InactiveAccountNoticeText( + title = stringResource(R.string.account_family_expired_title), + message = stringResource(R.string.account_family_expired_message), + action = stringResource(R.string.account_family_expired_action), + ) + } + +/** The link as a TV shows it: no scheme, since the viewer types it on another device. */ +val InactiveAccountNotice.renewUrlLabel: String + get() = renewUrl.removePrefix("https://") + +private const val INACTIVE_STATUS = "inactive" +private const val BILLING_URL = "https://app.put.io/billing" +private const val FAMILY_URL = "https://app.put.io/family" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9371458..a069831f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,14 @@ 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. + + Keep a good thing going! + Your family plan’s owner needs to update their payment details to keep your plan active. + If you’re ready to leave the nest, you can start your own subscription too! + Leave the family plan + diff --git a/app/src/mobile/kotlin/io/putdotio/android/MobileShell.kt b/app/src/mobile/kotlin/io/putdotio/android/MobileShell.kt index 8b5cf5ec..e264ceb9 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 @@ -350,6 +352,7 @@ internal fun MobileShell( playbackPlayerFactory = playbackPlayerFactory, onFilesEvent = { onFilesEvent(it) }, onFilesBack = onFilesBack, + inactiveNotice = account.inactiveNotice, ) { contentModifier -> MobileNavHost( transferDraft = transferDraft, @@ -468,6 +471,7 @@ private fun MobileChrome( playbackPlayerFactory: MobilePlayerFactory, onFilesEvent: (FilesBrowserEvent) -> Boolean, onFilesBack: () -> Unit, + inactiveNotice: InactiveAccountNotice?, content: @Composable (Modifier) -> Unit, ) { Row(modifier = Modifier.fillMaxSize()) { @@ -488,16 +492,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..91986b3e --- /dev/null +++ b/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt @@ -0,0 +1,71 @@ +package io.putdotio.android.account + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +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.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri + +internal const val MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG = "mobile-inactive-account-notice" + +/** The inactive-account banner under the top bar; its action opens app.put.io in the browser. */ +@Composable +internal fun MobileInactiveAccountNotice( + notice: InactiveAccountNotice, + modifier: Modifier = Modifier, + openUrl: (Context, String) -> Boolean = ::openInBrowser, +) { + val text = notice.text() + val context = LocalContext.current + // Without a browser the address is the only way forward, so it replaces the dead action. + var browserMissing by remember(notice) { mutableStateOf(false) } + Surface( + modifier = modifier + .fillMaxWidth() + .testTag(MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.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)) + } + if (browserMissing) { + Text( + text = notice.renewUrlLabel, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 12.dp), + ) + } else { + TextButton(onClick = { browserMissing = !openUrl(context, notice.renewUrl) }) { + Text(text.action) + } + } + } + } +} + +private fun openInBrowser(context: Context, url: String): Boolean = + try { + context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).addCategory(Intent.CATEGORY_BROWSABLE)) + true + } catch (_: ActivityNotFoundException) { + false + } 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 f1810d72..72680ae9 100644 --- a/app/src/mobile/kotlin/io/putdotio/android/auth/MobileAuthController.kt +++ b/app/src/mobile/kotlin/io/putdotio/android/auth/MobileAuthController.kt @@ -1,5 +1,6 @@ package io.putdotio.android.auth +import io.putdotio.android.account.InactiveAccountNotice import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -16,6 +17,7 @@ data class MobileAccount( val historyEnabled: Boolean = false, val avatarUrl: String? = null, val storage: MobileAccountStorage = MobileAccountStorage(), + val inactiveNotice: InactiveAccountNotice? = null, ) data class MobileAccountStorage( 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 476a922e..5729db28 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.sdk.PutioClient import io.putdotio.sdk.account.AccountInfo import io.putdotio.sdk.errors.PutioException @@ -126,4 +127,5 @@ private fun AccountInfo.toMobileAccount(): MobileAccount = sizeBytes = disk.size, usedBytes = disk.used, ), + 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..0d5c189c --- /dev/null +++ b/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt @@ -0,0 +1,75 @@ +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 is told the owner has to pay`() { + val notice = account(status = "inactive", deletesAt = "2026-10-15T08:00:00", subAccount = true) + .inactiveAccountNotice() + + assertEquals(InactiveAccountNotice.FamilyPlanExpired, notice) + assertEquals("https://app.put.io/family", notice?.renewUrl) + assertEquals("app.put.io/billing", InactiveAccountNotice.Deactivated(null).renewUrlLabel) + } + + @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..5bc0efec --- /dev/null +++ b/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt @@ -0,0 +1,90 @@ +package io.putdotio.android + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [35]) +class MobileInactiveAccountNoticeTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun deactivatedAccountShowsWebCopyWithTheDeletionCountdownAndOpensBilling() { + val zone = ZoneId.systemDefault() + val deletion = LocalDate.now(zone).plusDays(14).atTime(12, 0).atZone(zone).toInstant() + compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(deletion)) } } + + 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.onNodeWithText("Keep a good thing going!").performClick() + + val opened = shadowOf(compose.activity).nextStartedActivity + assertEquals(Intent.ACTION_VIEW, opened.action) + assertEquals("https://app.put.io/billing", opened.dataString) + } + + @Test + fun deletionTomorrowReadsInTheSingular() { + val zone = ZoneId.systemDefault() + val deletion = LocalDate.now(zone).plusDays(1).atTime(12, 0).atZone(zone).toInstant() + compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(deletion)) } } + + compose.onNodeWithText("Your files are still here, but they are scheduled to be deleted in 1 day.") + .assertIsDisplayed() + } + + @Test + fun withoutADeletionDateOnlyTheTitleAndActionShow() { + 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() + compose.onNodeWithText("Keep a good thing going!").assertIsDisplayed() + } + + @Test + fun familyPlanMemberIsSentToTheFamilyPage() { + compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.FamilyPlanExpired) } } + + compose.onNodeWithText( + "Your family plan’s owner needs to update their payment details to keep your plan active.", + ).assertIsDisplayed() + compose.onNodeWithText("If you’re ready to leave the nest, you can start your own subscription too!") + .assertIsDisplayed() + compose.onNodeWithText("Leave the family plan").performClick() + + assertEquals("https://app.put.io/family", shadowOf(compose.activity).nextStartedActivity.dataString) + } + + @Test + fun withoutABrowserTheAddressReplacesTheAction() { + compose.setContent { + PutioTheme { + MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(null), openUrl = { _, _ -> false }) + } + } + + compose.onNodeWithText("Keep a good thing going!").performClick() + + compose.onNodeWithText("app.put.io/billing").assertIsDisplayed() + compose.onNodeWithText("Keep a good thing going!").assertDoesNotExist() + } +} diff --git a/app/src/testMobile/kotlin/io/putdotio/android/MobileShellTest.kt b/app/src/testMobile/kotlin/io/putdotio/android/MobileShellTest.kt index 6e0e0197..6305ac6d 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 @@ -1076,6 +1078,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 491d339b..8168ee6a 100644 --- a/app/src/testMobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGatewayTest.kt +++ b/app/src/testMobile/kotlin/io/putdotio/android/auth/PutioAuthSessionGatewayTest.kt @@ -1,5 +1,6 @@ package io.putdotio.android.auth +import io.putdotio.android.account.InactiveAccountNotice import io.putdotio.sdk.account.AccountDisk import io.putdotio.sdk.account.AccountInfo import io.putdotio.sdk.account.AccountSettings @@ -34,6 +35,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) @@ -69,6 +81,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() @@ -86,7 +100,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..653d221c 100644 --- a/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt +++ b/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt @@ -63,6 +63,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 +137,46 @@ class TvShellTest { assertEquals(1, requests) } + @Test + fun inactiveAccountNoticeShowsAboveEveryPaneWithoutTakingFocus() { + 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.onNodeWithText("Keep a good thing going!").assertIsDisplayed() + compose.onNodeWithText("app.put.io/billing").assertIsDisplayed() + + 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("app.put.io/billing").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 c9799717..f570e995 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,5 +1,6 @@ package io.putdotio.android.tv.auth +import io.putdotio.android.account.InactiveAccountNotice import io.putdotio.sdk.account.AccountDisk import io.putdotio.sdk.account.AccountInfo import io.putdotio.sdk.account.AccountSettings @@ -17,6 +18,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 @@ -40,6 +42,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) @@ -72,6 +84,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() @@ -96,7 +109,8 @@ class PutioTvSessionGatewayTest { avatarUrl = "https://example.com/avatar.png", disk = AccountDisk(available = 1, size = 2, used = 1), settings = AccountSettings(sortBy = "NAME_ASC"), - 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..1941c291 --- /dev/null +++ b/app/src/tv/kotlin/io/putdotio/android/tv/account/TvInactiveAccountNotice.kt @@ -0,0 +1,63 @@ +package io.putdotio.android.tv.account + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.Alignment +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.renewUrlLabel +import io.putdotio.android.account.text +import io.putdotio.android.design.PutioDesignTokens + +/** + * The inactive-account banner above every pane. A TV cannot renew in place, so the web + * action becomes its address to visit on another device; 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), + ) + } + Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = text.action, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = notice.renewUrlLabel, + style = MaterialTheme.typography.titleSmall, + color = PutioDesignTokens.yellowSolid, + modifier = Modifier.padding(start = 12.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 957d8b31..eb4a089e 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,5 +1,7 @@ package io.putdotio.android.tv.auth +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.sdk.PutioClient @@ -19,6 +21,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, ) data class TvAccountStorage( @@ -117,6 +120,7 @@ internal fun AccountInfo.toTvAccount(): TvAccount = storage = TvAccountStorage(availableBytes = disk.available, sizeBytes = disk.size, usedBytes = disk.used), historyEnabled = settings.historyEnabled, avatarUrl = avatarUrl.takeIf { it.isNotBlank() }, + inactiveNotice = inactiveAccountNotice(), ) enum class TvLinkFailure { diff --git a/docs/behavior.md b/docs/behavior.md index 3aeabba1..84124d83 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -75,6 +75,29 @@ 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 copy and states +(`AccountStatusNotification`). A family plan member (`is_sub_account`) reads +that the plan's owner has to update their payment details, with Leave the +family plan pointing at `app.put.io/family`. Anyone else reads Your account has +been deactivated 😢, 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), and +Keep a good thing going! pointing at `app.put.io/billing`. 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. + +Mobile puts the notice under the top bar and opens the link in the browser; with +no browser the address replaces the action. TV puts it above the pane with the +address as text and no focus target, so D-pad entry into the pane is unchanged. +In-app renewal is out of scope. + +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 0064b9fa..c527172f 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -538,7 +538,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 From c4d68bd3ceca2b08d6c083f865820b8328938ac1 Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 1 Oct 2026 09:52:58 +0300 Subject: [PATCH 2/3] feat(account): drop billing links and calls to pay from the inactive-account notice Google Play's payments policy rules out web's billing and family links and its pay/renew/subscribe wording. Both surfaces now show the account status and the deletion countdown as plain text; the family plan title keeps only the fact. --- .../android/account/InactiveAccountNotice.kt | 27 ++------ app/src/main/res/values/strings.xml | 17 +++-- .../account/MobileInactiveAccountNotice.kt | 38 +---------- .../account/InactiveAccountNoticeTest.kt | 12 ++-- .../MobileInactiveAccountNoticeTest.kt | 66 +++++++++---------- .../kotlin/io/putdotio/android/TvShellTest.kt | 8 ++- .../tv/account/TvInactiveAccountNotice.kt | 22 +------ docs/behavior.md | 29 ++++---- 8 files changed, 77 insertions(+), 142 deletions(-) diff --git a/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt b/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt index a3fe3600..9c89c9a3 100644 --- a/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt +++ b/app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt @@ -13,23 +13,19 @@ 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 owner - * has to pay, anyone else that the account is deactivated and when the files go. + * 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 { - val renewUrl: String - /** [filesDeletedAt] is null when put.io sent no usable deletion date; the notice then has no message. */ data class Deactivated( val filesDeletedAt: Instant?, - ) : InactiveAccountNotice { - override val renewUrl: String get() = BILLING_URL - } + ) : InactiveAccountNotice - data object FamilyPlanExpired : InactiveAccountNotice { - override val renewUrl: String get() = FAMILY_URL - } + data object FamilyPlanExpired : InactiveAccountNotice } internal fun AccountInfo.inactiveAccountNotice(): InactiveAccountNotice? = @@ -50,7 +46,6 @@ fun InactiveAccountNotice.Deactivated.daysUntilFilesDeleted(now: Instant, zone: data class InactiveAccountNoticeText( val title: String, val message: String?, - val action: String, ) @Composable @@ -65,20 +60,12 @@ fun InactiveAccountNotice.text( message = daysUntilFilesDeleted(now, zone)?.toInt()?.let { days -> pluralStringResource(R.plurals.account_inactive_files_deletion, days, days) }, - action = stringResource(R.string.account_inactive_renew), ) InactiveAccountNotice.FamilyPlanExpired -> InactiveAccountNoticeText( title = stringResource(R.string.account_family_expired_title), - message = stringResource(R.string.account_family_expired_message), - action = stringResource(R.string.account_family_expired_action), + message = null, ) } -/** The link as a TV shows it: no scheme, since the viewer types it on another device. */ -val InactiveAccountNotice.renewUrlLabel: String - get() = renewUrl.removePrefix("https://") - private const val INACTIVE_STATUS = "inactive" -private const val BILLING_URL = "https://app.put.io/billing" -private const val FAMILY_URL = "https://app.put.io/family" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a069831f..f8938f0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,14 +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. - Keep a good thing going! - Your family plan’s owner needs to update their payment details to keep your plan active. - If you’re ready to leave the nest, you can start your own subscription too! - Leave the family plan + + Your family plan is no longer active. diff --git a/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt b/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt index 91986b3e..18a9b982 100644 --- a/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt +++ b/app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt @@ -1,39 +1,25 @@ package io.putdotio.android.account -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent 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.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import androidx.core.net.toUri internal const val MOBILE_INACTIVE_ACCOUNT_NOTICE_TAG = "mobile-inactive-account-notice" -/** The inactive-account banner under the top bar; its action opens app.put.io in the browser. */ +/** 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, - openUrl: (Context, String) -> Boolean = ::openInBrowser, ) { val text = notice.text() - val context = LocalContext.current - // Without a browser the address is the only way forward, so it replaces the dead action. - var browserMissing by remember(notice) { mutableStateOf(false) } Surface( modifier = modifier .fillMaxWidth() @@ -41,31 +27,11 @@ internal fun MobileInactiveAccountNotice( color = MaterialTheme.colorScheme.secondaryContainer, contentColor = MaterialTheme.colorScheme.onSurface, ) { - Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp)) { + 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)) } - if (browserMissing) { - Text( - text = notice.renewUrlLabel, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(vertical = 12.dp), - ) - } else { - TextButton(onClick = { browserMissing = !openUrl(context, notice.renewUrl) }) { - Text(text.action) - } - } } } } - -private fun openInBrowser(context: Context, url: String): Boolean = - try { - context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()).addCategory(Intent.CATEGORY_BROWSABLE)) - true - } catch (_: ActivityNotFoundException) { - false - } diff --git a/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt b/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt index 0d5c189c..4f348777 100644 --- a/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt +++ b/app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt @@ -34,13 +34,11 @@ class InactiveAccountNoticeTest { } @Test - fun `an inactive family plan member is told the owner has to pay`() { - val notice = account(status = "inactive", deletesAt = "2026-10-15T08:00:00", subAccount = true) - .inactiveAccountNotice() - - assertEquals(InactiveAccountNotice.FamilyPlanExpired, notice) - assertEquals("https://app.put.io/family", notice?.renewUrl) - assertEquals("app.put.io/billing", InactiveAccountNotice.Deactivated(null).renewUrlLabel) + 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 diff --git a/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt b/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt index 5bc0efec..c4a4c039 100644 --- a/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt +++ b/app/src/testMobile/kotlin/io/putdotio/android/MobileInactiveAccountNoticeTest.kt @@ -1,11 +1,15 @@ package io.putdotio.android -import android.content.Intent 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.performClick +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 @@ -16,7 +20,6 @@ import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) @@ -26,65 +29,58 @@ class MobileInactiveAccountNoticeTest { val compose = createAndroidComposeRule() @Test - fun deactivatedAccountShowsWebCopyWithTheDeletionCountdownAndOpensBilling() { - val zone = ZoneId.systemDefault() - val deletion = LocalDate.now(zone).plusDays(14).atTime(12, 0).atZone(zone).toInstant() - compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(deletion)) } } + 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() - compose.onNodeWithText("Keep a good thing going!").performClick() - - val opened = shadowOf(compose.activity).nextStartedActivity - assertEquals(Intent.ACTION_VIEW, opened.action) - assertEquals("https://app.put.io/billing", opened.dataString) + assertNoActionOrPaymentWording() } @Test fun deletionTomorrowReadsInTheSingular() { - val zone = ZoneId.systemDefault() - val deletion = LocalDate.now(zone).plusDays(1).atTime(12, 0).atZone(zone).toInstant() - compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(deletion)) } } + 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 withoutADeletionDateOnlyTheTitleAndActionShow() { + 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() - compose.onNodeWithText("Keep a good thing going!").assertIsDisplayed() + assertNoActionOrPaymentWording() } @Test - fun familyPlanMemberIsSentToTheFamilyPage() { + fun familyPlanMemberIsToldThePlanExpired() { compose.setContent { PutioTheme { MobileInactiveAccountNotice(InactiveAccountNotice.FamilyPlanExpired) } } - compose.onNodeWithText( - "Your family plan’s owner needs to update their payment details to keep your plan active.", - ).assertIsDisplayed() - compose.onNodeWithText("If you’re ready to leave the nest, you can start your own subscription too!") - .assertIsDisplayed() - compose.onNodeWithText("Leave the family plan").performClick() - - assertEquals("https://app.put.io/family", shadowOf(compose.activity).nextStartedActivity.dataString) + compose.onNodeWithText("Your family plan is no longer active.").assertIsDisplayed() + assertNoActionOrPaymentWording() } - @Test - fun withoutABrowserTheAddressReplacesTheAction() { - compose.setContent { - PutioTheme { - MobileInactiveAccountNotice(InactiveAccountNotice.Deactivated(null), openUrl = { _, _ -> false }) - } + /** 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()) + } - compose.onNodeWithText("Keep a good thing going!").performClick() + private fun deletionIn(days: Long) = ZoneId.systemDefault().let { zone -> + LocalDate.now(zone).plusDays(days).atTime(12, 0).atZone(zone).toInstant() + } - compose.onNodeWithText("app.put.io/billing").assertIsDisplayed() - compose.onNodeWithText("Keep a good thing going!").assertDoesNotExist() + 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/testTv/kotlin/io/putdotio/android/TvShellTest.kt b/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt index 653d221c..5a8fbf90 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 @@ -157,8 +159,8 @@ class TvShellTest { 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.onNodeWithText("Keep a good thing going!").assertIsDisplayed() - compose.onNodeWithText("app.put.io/billing").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) @@ -174,7 +176,7 @@ class TvShellTest { keyUp(Key.DirectionCenter) } compose.onNodeWithText("Sign out").assertIsFocused() - compose.onNodeWithText("app.put.io/billing").assertIsDisplayed() + compose.onNodeWithText("Your account has been deactivated 😢").assertIsDisplayed() } @Test 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 index 1941c291..fd7093b7 100644 --- a/app/src/tv/kotlin/io/putdotio/android/tv/account/TvInactiveAccountNotice.kt +++ b/app/src/tv/kotlin/io/putdotio/android/tv/account/TvInactiveAccountNotice.kt @@ -2,25 +2,18 @@ package io.putdotio.android.tv.account import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row 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.Alignment 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.renewUrlLabel import io.putdotio.android.account.text -import io.putdotio.android.design.PutioDesignTokens -/** - * The inactive-account banner above every pane. A TV cannot renew in place, so the web - * action becomes its address to visit on another device; nothing here takes D-pad focus. - */ +/** The inactive-account banner above every pane; plain text, so nothing here takes D-pad focus. */ @Composable internal fun TvInactiveAccountNotice( notice: InactiveAccountNotice, @@ -46,18 +39,5 @@ internal fun TvInactiveAccountNotice( modifier = Modifier.padding(top = 4.dp), ) } - Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Text( - text = text.action, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = notice.renewUrlLabel, - style = MaterialTheme.typography.titleSmall, - color = PutioDesignTokens.yellowSolid, - modifier = Modifier.padding(start = 12.dp), - ) - } } } diff --git a/docs/behavior.md b/docs/behavior.md index abe97403..460c734a 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -78,21 +78,20 @@ 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 copy and states -(`AccountStatusNotification`). A family plan member (`is_sub_account`) reads -that the plan's owner has to update their payment details, with Leave the -family plan pointing at `app.put.io/family`. Anyone else reads Your account has -been deactivated 😢, 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), and -Keep a good thing going! pointing at `app.put.io/billing`. 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. - -Mobile puts the notice under the top bar and opens the link in the browser; with -no browser the address replaces the action. TV puts it above the pane with the -address as text and no focus target, so D-pad entry into the pane is unchanged. -In-app renewal is out of scope. +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`, From 0800998d46ddd561aae975257dcbd7b0086d3cd2 Mon Sep 17 00:00:00 2001 From: Altay Date: Thu, 1 Oct 2026 09:57:53 +0300 Subject: [PATCH 3/3] test(account): prove Back still returns to Files with the inactive notice on TV --- app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt b/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt index 5a8fbf90..4de3537b 100644 --- a/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt +++ b/app/src/testTv/kotlin/io/putdotio/android/TvShellTest.kt @@ -140,7 +140,7 @@ class TvShellTest { } @Test - fun inactiveAccountNoticeShowsAboveEveryPaneWithoutTakingFocus() { + 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 { @@ -177,6 +177,11 @@ class TvShellTest { } 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