diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 8358e3871f..2447d68b16 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -57,6 +57,9 @@ fun ULong?.formatToString(pattern: String = DatePattern.DATE_TIME): String? { return this?.let { Instant.ofEpochSecond(toLong()).formatted(pattern) } } +fun String.toEpochSecondsOrNull(): ULong? = + runCatching { Instant.parse(this).epochSecond }.getOrNull()?.takeIf { it >= 0 }?.toULong() + fun Long.toTimeUTC(): String { val instant = Instant.ofEpochMilli(this) val dateTime = LocalDateTime.ofInstant(instant, ZoneId.of("UTC")) @@ -239,6 +242,7 @@ enum class UiDateStyle { DATE, DATE_TIME, DATE_TIME_YEAR, + DATE_TIME_YEAR_SHORT, ; fun pattern(is24Hour: Boolean): String { @@ -248,6 +252,7 @@ enum class UiDateStyle { DATE -> DAY DATE_TIME -> "$DAY, $time" DATE_TIME_YEAR -> "$DAY_WITH_YEAR, $time" + DATE_TIME_YEAR_SHORT -> "${DatePattern.DATE_FORMAT}, $time" } } @@ -261,7 +266,6 @@ enum class UiDateStyle { object DatePattern { const val DATE_TIME = "dd/MM/yyyy, HH:mm" - const val CHANNEL_DETAILS = "MMM d, yyyy, HH:mm" const val LOG_FILE = "yyyy-MM-dd_HH-mm-ss" const val LOG_LINE = "yyyy-MM-dd HH:mm:ss.SSS" diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index ae3bea0b6e..9c919e89e1 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -56,11 +56,12 @@ import kotlinx.collections.immutable.persistentListOf import org.lightningdevkit.ldknode.OutPoint import to.bitkit.R import to.bitkit.env.Env -import to.bitkit.ext.DatePattern +import to.bitkit.ext.UiDateStyle import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.resolveDisplayShortChannelId import to.bitkit.ext.setClipboardText +import to.bitkit.ext.toEpochSecondsOrNull import to.bitkit.models.Toast import to.bitkit.models.msatFloorOf import to.bitkit.ui.Routes @@ -83,10 +84,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.getBlockExplorerUrl -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Locale +import to.bitkit.ui.utils.uiDateText @Composable fun ChannelDetailScreen( @@ -297,7 +295,7 @@ private fun ChannelDetailContent( SectionRow( name = stringResource(R.string.lightning__created_on), valueContent = { - CaptionB(text = formatDate(createdAt)) + CaptionB(text = channelDateText(createdAt)) } ) @@ -307,7 +305,7 @@ private fun ChannelDetailContent( SectionRow( name = stringResource(R.string.lightning__order_expiry), valueContent = { - CaptionB(text = formatDate(blocktankOrder.orderExpiresAt)) + CaptionB(text = channelDateText(blocktankOrder.orderExpiresAt)) } ) } @@ -420,7 +418,7 @@ private fun ChannelDetailContent( SectionRow( name = stringResource(R.string.lightning__opened_on), valueContent = { - CaptionB(text = formatUnixTimestamp(txTime.toLong())) + CaptionB(text = uiDateText(txTime, UiDateStyle.DATE_TIME_YEAR_SHORT)) } ) } @@ -434,7 +432,7 @@ private fun ChannelDetailContent( SectionRow( name = stringResource(R.string.lightning__closed_on), valueContent = { - CaptionB(text = formatDate(closedAt)) + CaptionB(text = channelDateText(closedAt)) } ) } @@ -585,22 +583,10 @@ private fun getChannelStatus( return if (channel.details.isChannelReady) ChannelStatusUi.OPEN else ChannelStatusUi.PENDING } -private fun formatDate(dateString: String): String { - return runCatching { - val instant = Instant.parse(dateString) - val formatter = DateTimeFormatter.ofPattern(DatePattern.CHANNEL_DETAILS, Locale.getDefault()) - .withZone(ZoneId.systemDefault()) - formatter.format(instant) - }.getOrDefault(dateString) -} - -private fun formatUnixTimestamp(timestamp: Long): String { - return runCatching { - val instant = Instant.ofEpochSecond(timestamp) - val formatter = DateTimeFormatter.ofPattern(DatePattern.CHANNEL_DETAILS, Locale.getDefault()) - .withZone(ZoneId.systemDefault()) - formatter.format(instant) - }.getOrDefault(timestamp.toString()) +@Composable +private fun channelDateText(dateString: String): String { + val timestamp = remember(dateString) { dateString.toEpochSecondsOrNull() } + return if (timestamp != null) uiDateText(timestamp, UiDateStyle.DATE_TIME_YEAR_SHORT) else dateString } private fun contactSupport( diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt index cc21950985..7663215df6 100644 --- a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -10,6 +10,7 @@ import java.util.Locale import java.util.concurrent.TimeUnit import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.time.ExperimentalTime @@ -299,5 +300,75 @@ class DateTimeExtTest : BaseUnitTest() { } } + @Test + fun `toEpochSecondsOrNull parses an ISO-8601 instant`() { + val result = "2026-03-07T15:23:00Z".toEpochSecondsOrNull() + + assertEquals(AFTERNOON.epochSecond.toULong(), result) + } + + @Test + fun `toEpochSecondsOrNull parses the millisecond form Blocktank returns`() { + val result = "2026-03-07T15:23:00.000Z".toEpochSecondsOrNull() + + assertEquals(AFTERNOON.epochSecond.toULong(), result) + } + + @Test + fun `toEpochSecondsOrNull returns null for an unparseable string`() { + val result = "not a date".toEpochSecondsOrNull() + + assertNull(result) + } + + @Test + fun `toEpochSecondsOrNull returns null for a pre-1970 instant`() { + val result = "1969-12-31T23:59:59Z".toEpochSecondsOrNull() + + assertNull(result) + } + + @Test + fun `toEpochSecondsOrNull parses the epoch start`() { + val result = "1970-01-01T00:00:00Z".toEpochSecondsOrNull() + + assertEquals(0uL, result) + } + + @Test + fun `toEpochSecondsOrNull returns null for an empty string`() { + val result = "".toEpochSecondsOrNull() + + assertNull(result) + } + + @Test + fun `DATE_TIME_YEAR_SHORT abbreviates the month and applies the selected clock format`() { + val in24Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE_TIME_YEAR_SHORT.pattern(is24Hour = true)) + val in12Hour = AFTERNOON.formattedInUtc(UiDateStyle.DATE_TIME_YEAR_SHORT.pattern(is24Hour = false)) + + assertEquals("Mar 7, 2026, 15:23", in24Hour) + assertEquals("Mar 7, 2026, 3:23 PM", in12Hour) + } + + @Test + fun `DATE_TIME_YEAR_SHORT localizes the month name`() { + val pattern = UiDateStyle.DATE_TIME_YEAR_SHORT.pattern(is24Hour = true) + val result = AFTERNOON.formatted(pattern, Locale.GERMANY, UTC) + + assertEquals("März 7, 2026, 15:23", result) + } + + @Test + fun `toEpochSecondsOrNull round-trips through the channel details format`() { + val timestamp = "2026-03-07T15:23:00.000Z".toEpochSecondsOrNull() + + assertNotNull(timestamp) + val result = Instant.ofEpochSecond(timestamp.toLong()) + .formatted(UiDateStyle.DATE_TIME_YEAR_SHORT.pattern(is24Hour = true), Locale.US, UTC) + + assertEquals("Mar 7, 2026, 15:23", result) + } + private fun Instant.formattedInUtc(pattern: String) = formatted(pattern, Locale.US, UTC) } diff --git a/changelog.d/next/1123.fixed.md b/changelog.d/next/1123.fixed.md new file mode 100644 index 0000000000..d464fe7e11 --- /dev/null +++ b/changelog.d/next/1123.fixed.md @@ -0,0 +1 @@ +Lightning connection timestamps now follow the device's 12/24-hour time setting. diff --git a/journeys/README.md b/journeys/README.md index f1d8ab2005..9118a8cde7 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -123,6 +123,7 @@ fixtures, push notifications) live in each suite's README. | [deeplinks](deeplinks) | 2 | `bitkit://screen/…` and sheet routing behind the dev-mode gate; no README | | [hardware-wallet](hardware-wallet) | 17 | Trezor over USB; needs the Trezor emulator | | [home](home) | 1 | Pull to refresh on Home; checks the app log, no README | +| [lightning-connections](lightning-connections) | 1 | Connection details date/time format; no README | | [lnurl](lnurl) | 1 | LNURL-pay comment kept as the activity note; needs an LNURL-pay endpoint that allows comments; no README | | [node-lifecycle](node-lifecycle) | 1 | Detached LDK restart completes; a cancelled RGS server change reconciles and recovers to Running; reads the app log; no README | | [notification-permission](notification-permission) | 4 | Background-setup toggles | @@ -162,6 +163,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `payment-requests/requested-resolution-failure.xml` | not ported | | `node-lifecycle/cancelled-node-restart.xml` | not ported — the routes run through Android's LDK Debug and Rapid-Gossip-Sync screens and assert on Android app-log lines | | `restore-wallet/paste-seed-fragment.xml` | not ported — the iOS Restore screen still has the 12/24-only paste guard, so the behaviour does not exist there yet | +| `lightning-connections/channel-details-time-format.xml` | not ported — `LightningConnectionDetailView.swift` hardcodes `MMM d, yyyy - HH:mm`, so iOS has no 12-hour behaviour to assert | | `send/own-invoice-guard.xml` | not ported — iOS has no own-invoice guard | | `settings/electrum-server-error-toasts.xml` | not ported — iOS still shows one generic message for every manual Electrum connect failure | | `transfers/closed-channel-transfer-settles.xml` | not ported — the closed-channel and order-closure settle rules are an iOS follow-up | diff --git a/journeys/lightning-connections/channel-details-time-format.xml b/journeys/lightning-connections/channel-details-time-format.xml new file mode 100644 index 0000000000..ea7493b690 --- /dev/null +++ b/journeys/lightning-connections/channel-details-time-format.xml @@ -0,0 +1,33 @@ + + + Proves the Connection details screen follows the device 12/24-hour setting for its dates, and + updates while the app stays open. Precondition: onboarded dev wallet, English locale, and at least + one open Lightning connection opened with Transfer to Spending, paid from savings. "Created on" + comes from the Blocktank order, so any bought connection shows it, but "Opened on" is read from the + on-chain transfer that funded the channel, so a CJIT connection — paid over Lightning — never shows + it; pick a transfer-funded connection or the "Opened on" steps cannot pass. Before starting, record + `adb shell settings get system time_12_24` (null means Automatic) and restore it at the end. Change + the format through the system Settings UI, not `settings put`: the app listens for + ACTION_TIME_CHANGED, which only the Settings app broadcasts. "Order Expiry" shows only while the + order is unopened and "Closed on" only for a closed connection; check them the same way when present. + + + Run `adb shell am start -a android.settings.DATE_SETTINGS`, tap "12-hour / 24-hour format" and select "12-hour format" + Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` + Tap the menu icon (testTag "HeaderMenu") + Tap "Settings" (testTag "DrawerSettings") + Tap the "Advanced" tab (testTag "Tab-advanced") + Tap "Lightning Connections" (testTag "Channels") + Tap the connection opened with Transfer to Spending (testTag "Channel"; all rows share the tag, so with several connections take the one funded from savings) + Verify that the "Created on" value reads like "Sep 15, 2026, 2:44 PM", ending in AM or PM with no leading zero on the hour + Scroll down until "Opened on" is visible + Verify that the "Opened on" value ends in AM or PM + Run `adb shell am start -a android.settings.DATE_SETTINGS`, tap "12-hour / 24-hour format" and select "24-hour format" + Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` + Verify that the Connection details screen is still visible and the "Opened on" value reads like "Sep 15, 2026, 14:44", with no AM or PM + Scroll up until "Created on" is visible + Verify that the "Created on" value uses the 24-hour clock with no AM or PM + Run `adb shell am start -a android.settings.DATE_SETTINGS`, tap "12-hour / 24-hour format" and select the option recorded before starting ("Automatic" when it was null) + Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity` + +