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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
altaywtf marked this conversation as resolved.
*/
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"
19 changes: 18 additions & 1 deletion app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
<resources>
<string name="app_name">put.io</string>
</resources>

<!--
Inactive account notice, from putio-web AccountStatusNotification and
packages/translations/src/languages/en.json. Google Play's payments policy
keeps out web's calls to pay, renew or subscribe and its billing and family
links (app_expired_account_cta_label, app_expired_account_family_cta_label,
app_expired_account_family_member_message).
-->
<!-- en.json app_expired_account_title, verbatim. -->
<string name="account_inactive_title">Your account has been deactivated 😢</string>
<!-- en.json app_passive_account_file_delete_message; web always says "days". -->
<plurals name="account_inactive_files_deletion">
<item quantity="one">Your files are still here, but they are scheduled to be deleted in %d day.</item>
<item quantity="other">Your files are still here, but they are scheduled to be deleted in %d days.</item>
</plurals>
<!-- en.json app_expired_account_family_member_title ("Your family plan's owner needs to update
their payment details to keep your plan active."), without the call to pay. -->
<string name="account_family_expired_title">Your family plan is no longer active.</string>
</resources>
33 changes: 23 additions & 10 deletions app/src/mobile/kotlin/io/putdotio/android/MobileShell.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -346,6 +348,7 @@ internal fun MobileShell(
playbackPlayerFactory = playbackPlayerFactory,
onFilesEvent = { onFilesEvent(it) },
onFilesBack = onFilesBack,
inactiveNotice = account.inactiveNotice,
) { contentModifier ->
MobileNavHost(
transferDraft = transferDraft,
Expand Down Expand Up @@ -464,6 +467,7 @@ private fun MobileChrome(
playbackPlayerFactory: MobilePlayerFactory,
onFilesEvent: (FilesBrowserEvent) -> Boolean,
onFilesBack: () -> Unit,
inactiveNotice: InactiveAccountNotice?,
content: @Composable (Modifier) -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +18,7 @@ data class MobileAccount(
val historyEnabled: Boolean = false,
val avatarUrl: String? = null,
val storage: AccountStorage = AccountStorage(),
val inactiveNotice: InactiveAccountNotice? = null,
)

@JvmInline
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -122,4 +123,5 @@ private fun AccountInfo.toMobileAccount(): MobileAccount =
historyEnabled = settings.historyEnabled,
avatarUrl = avatarUrl,
storage = toAccountStorage(),
inactiveNotice = inactiveAccountNotice(),
)
Original file line number Diff line number Diff line change
@@ -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,
)
}
Loading