Skip to content
Open
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
6 changes: 5 additions & 1 deletion app/src/main/java/to/bitkit/ext/DateTime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -239,6 +242,7 @@ enum class UiDateStyle {
DATE,
DATE_TIME,
DATE_TIME_YEAR,
DATE_TIME_YEAR_SHORT,
;

fun pattern(is24Hour: Boolean): String {
Expand All @@ -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"
}
}

Expand All @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -297,7 +295,7 @@ private fun ChannelDetailContent(
SectionRow(
name = stringResource(R.string.lightning__created_on),
valueContent = {
CaptionB(text = formatDate(createdAt))
CaptionB(text = channelDateText(createdAt))
}
)

Expand All @@ -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))
}
)
}
Expand Down Expand Up @@ -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))
}
)
}
Expand All @@ -434,7 +432,7 @@ private fun ChannelDetailContent(
SectionRow(
name = stringResource(R.string.lightning__closed_on),
valueContent = {
CaptionB(text = formatDate(closedAt))
CaptionB(text = channelDateText(closedAt))
}
)
}
Expand Down Expand Up @@ -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(
Expand Down
71 changes: 71 additions & 0 deletions app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions changelog.d/next/1123.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Lightning connection timestamps now follow the device's 12/24-hour time setting.
2 changes: 2 additions & 0 deletions journeys/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
33 changes: 33 additions & 0 deletions journeys/lightning-connections/channel-details-time-format.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<journey name="channel details time format">
<description>
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.
</description>
<actions>
<action>Run `adb shell am start -a android.settings.DATE_SETTINGS`, tap "12-hour / 24-hour format" and select "12-hour format"</action>
<action>Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity`</action>
<action>Tap the menu icon (testTag "HeaderMenu")</action>
<action>Tap "Settings" (testTag "DrawerSettings")</action>
<action>Tap the "Advanced" tab (testTag "Tab-advanced")</action>
<action>Tap "Lightning Connections" (testTag "Channels")</action>
<action>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)</action>
<action>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</action>
<action>Scroll down until "Opened on" is visible</action>
<action>Verify that the "Opened on" value ends in AM or PM</action>
<action>Run `adb shell am start -a android.settings.DATE_SETTINGS`, tap "12-hour / 24-hour format" and select "24-hour format"</action>
<action>Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity`</action>
<action>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</action>
<action>Scroll up until "Created on" is visible</action>
<action>Verify that the "Created on" value uses the 24-hour clock with no AM or PM</action>
<action>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)</action>
<action>Run `adb shell am start -n to.bitkit.dev/to.bitkit.ui.MainActivity`</action>
</actions>
</journey>
Loading