From 2b8419ef013e1b791f0132babff2fbe37498b9d6 Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Fri, 31 Jul 2026 19:18:57 +0300 Subject: [PATCH 1/6] fix: use system time format in channel details --- app/src/main/java/to/bitkit/ext/DateTime.kt | 7 ++- .../settings/lightning/ChannelDetailScreen.kt | 36 ++++-------- .../java/to/bitkit/ext/DateTimeExtTest.kt | 57 +++++++++++++++++++ changelog.d/next/1110.fixed.md | 1 + 4 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 changelog.d/next/1110.fixed.md diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 69c94349d5..f9ace59142 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -56,6 +56,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.toULong() }.getOrNull() + fun Long.toTimeUTC(): String { val instant = Instant.ofEpochMilli(this) val dateTime = LocalDateTime.ofInstant(instant, ZoneId.of("UTC")) @@ -238,6 +241,7 @@ enum class UiDateStyle { DATE, DATE_TIME, DATE_TIME_YEAR, + DATE_TIME_YEAR_SHORT, ; fun pattern(is24Hour: Boolean): String { @@ -247,6 +251,7 @@ enum class UiDateStyle { DATE -> DAY DATE_TIME -> "$DAY, $time" DATE_TIME_YEAR -> "$DAY_WITH_YEAR, $time" + DATE_TIME_YEAR_SHORT -> "$SHORT_DAY_WITH_YEAR, $time" } } @@ -255,12 +260,12 @@ enum class UiDateStyle { const val TIME_24H = "HH:mm" const val DAY = "MMMM d" const val DAY_WITH_YEAR = "MMMM d yyyy" + const val SHORT_DAY_WITH_YEAR = "MMM d, yyyy" } } 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 5332e70919..19050e6ec9 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 @@ -261,5 +262,61 @@ class DateTimeExtTest : BaseUnitTest() { assertEquals(UiDateStyle.DATE_TIME, uiDateStyleFor(lateEvening.epochSecond.toULong(), today, bucharest)) } + @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 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/1110.fixed.md b/changelog.d/next/1110.fixed.md new file mode 100644 index 0000000000..d464fe7e11 --- /dev/null +++ b/changelog.d/next/1110.fixed.md @@ -0,0 +1 @@ +Lightning connection timestamps now follow the device's 12/24-hour time setting. From a01e1920be50050df93a581cf40804093135a08d Mon Sep 17 00:00:00 2001 From: Guzinowich Date: Fri, 31 Jul 2026 19:54:31 +0300 Subject: [PATCH 2/6] chore: rename changelog fragment --- changelog.d/next/{1110.fixed.md => 1123.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{1110.fixed.md => 1123.fixed.md} (100%) diff --git a/changelog.d/next/1110.fixed.md b/changelog.d/next/1123.fixed.md similarity index 100% rename from changelog.d/next/1110.fixed.md rename to changelog.d/next/1123.fixed.md From e201c7e7106ba3b3166e7fdea3302ec298e3d3bb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 16 Sep 2026 15:31:23 -0300 Subject: [PATCH 3/6] fix: reuse date format and reject pre-1970 instants Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/java/to/bitkit/ext/DateTime.kt | 5 ++--- app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index 5519224a1e..2447d68b16 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -58,7 +58,7 @@ fun ULong?.formatToString(pattern: String = DatePattern.DATE_TIME): String? { } fun String.toEpochSecondsOrNull(): ULong? = - runCatching { Instant.parse(this).epochSecond.toULong() }.getOrNull() + runCatching { Instant.parse(this).epochSecond }.getOrNull()?.takeIf { it >= 0 }?.toULong() fun Long.toTimeUTC(): String { val instant = Instant.ofEpochMilli(this) @@ -252,7 +252,7 @@ enum class UiDateStyle { DATE -> DAY DATE_TIME -> "$DAY, $time" DATE_TIME_YEAR -> "$DAY_WITH_YEAR, $time" - DATE_TIME_YEAR_SHORT -> "$SHORT_DAY_WITH_YEAR, $time" + DATE_TIME_YEAR_SHORT -> "${DatePattern.DATE_FORMAT}, $time" } } @@ -261,7 +261,6 @@ enum class UiDateStyle { const val TIME_24H = "HH:mm" const val DAY = "MMMM d" const val DAY_WITH_YEAR = "MMMM d yyyy" - const val SHORT_DAY_WITH_YEAR = "MMM d, yyyy" } } diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt index 8e9ea22aa1..7663215df6 100644 --- a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -321,6 +321,20 @@ class DateTimeExtTest : BaseUnitTest() { 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() From 6f4adcfa449c227b5fbd18d33f02591d8de0bbca Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 10:15:23 -0300 Subject: [PATCH 4/6] docs: add channel details time format journey Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 ++ .../channel-details-time-format.xml | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 journeys/lightning-connections/channel-details-time-format.xml diff --git a/journeys/README.md b/journeys/README.md index 403c5e2309..53cb702d14 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -118,6 +118,7 @@ fixtures, push notifications) live in each suite's README. | [cjit-notifications](cjit-notifications) | 3 | CJIT channel-ready notifications; needs FCM push | | [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 | +| [lightning-connections](lightning-connections) | 1 | Connection details date/time format; no README | | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required | @@ -141,6 +142,7 @@ Known differences in the corpus, as of the iOS port (synonymdev/bitkit-ios#691): | `hardware-wallet/usb-reconnect.xml` | `reconnect.xml` — over Bridge, since iOS cannot do WebUSB | | `hardware-wallet/receive-onchain.xml`, `hardware-wallet/send-onchain.xml` | not ported | | `payment-requests/requested-resolution-failure.xml` | not ported | +| `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 | | `deeplinks/*` | not ported — iOS registers the `bitkit` scheme but has no screen or sheet router | | — | `hardware-wallet/transfer-to-spending-over-max.xml` exists only on iOS | 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..ac7bdef9f8 --- /dev/null +++ b/journeys/lightning-connections/channel-details-time-format.xml @@ -0,0 +1,30 @@ + + + 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, at least one + open Lightning connection bought from Blocktank (so "Created on" and "Opened on" are shown). 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 first connection (testTag "Channel") + 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` + + From 79bb2c8e838271fa2ebef4b7a5e3611090171901 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Fri, 18 Sep 2026 13:35:40 -0300 Subject: [PATCH 5/6] docs: require a transfer-funded connection in the journey Co-Authored-By: Claude Opus 5 (1M context) --- .../channel-details-time-format.xml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/journeys/lightning-connections/channel-details-time-format.xml b/journeys/lightning-connections/channel-details-time-format.xml index ac7bdef9f8..ea7493b690 100644 --- a/journeys/lightning-connections/channel-details-time-format.xml +++ b/journeys/lightning-connections/channel-details-time-format.xml @@ -1,10 +1,13 @@ 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, at least one - open Lightning connection bought from Blocktank (so "Created on" and "Opened on" are shown). 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 + 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. @@ -15,7 +18,7 @@ Tap "Settings" (testTag "DrawerSettings") Tap the "Advanced" tab (testTag "Tab-advanced") Tap "Lightning Connections" (testTag "Channels") - Tap the first connection (testTag "Channel") + 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 From d2f3fadc6dbd7f0318de422cabf48204a82ce7dd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 21 Sep 2026 08:24:34 -0300 Subject: [PATCH 6/6] docs: restore journey suite table order after merge Co-Authored-By: Claude Opus 5 (1M context) --- journeys/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/README.md b/journeys/README.md index d383b0c7b2..bd69decf6a 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -122,8 +122,8 @@ 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 | -| [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 | | [lightning-connections](lightning-connections) | 1 | Connection details date/time format; 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 | | [payment-requests](payment-requests) | 2 | Requires a linked fixture issuer; rejected shapes are unit fixtures | | [pubky-marketplace](pubky-marketplace) | 1 | Two-wallet Paykit marketplace payment; integration fixture required |