-
Notifications
You must be signed in to change notification settings - Fork 0
feat(account): show the inactive-account status and file deletion countdown #265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
9913662
feat(account): warn when the account is inactive and files are schedu…
altaywtf 02352b0
Merge remote-tracking branch 'origin/main' into feat/inactive-account…
altaywtf c4d68bd
feat(account): drop billing links and calls to pay from the inactive-…
altaywtf 0800998
test(account): prove Back still returns to Files with the inactive no…
altaywtf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
71 changes: 71 additions & 0 deletions
71
app/src/main/kotlin/io/putdotio/android/account/InactiveAccountNotice.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| */ | ||
| 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" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
37 changes: 37 additions & 0 deletions
37
app/src/mobile/kotlin/io/putdotio/android/account/MobileInactiveAccountNotice.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
73 changes: 73 additions & 0 deletions
73
app/src/test/kotlin/io/putdotio/android/account/InactiveAccountNoticeTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.