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