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 @@ -100,7 +100,7 @@ class LightningNodeService : Service() {
}

private fun createNotification(
contentText: String = getString(R.string.notification_running_in_background),
contentText: String = getString(R.string.notification__service__body),
): Notification {
val notificationIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
Expand All @@ -120,7 +120,7 @@ class LightningNodeService : Service() {
.setContentIntent(pendingIntent)
.addAction(
R.drawable.ic_x,
getString(R.string.notification_stop_app),
getString(R.string.notification__service__stop),
stopPendingIntent
)
.build()
Expand Down
8 changes: 6 additions & 2 deletions app/src/main/java/to/bitkit/async/ServiceQueue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ enum class ServiceQueue {
): T {
return runBlocking(coroutineContext) {
try {
measured(functionName) {
measured(label = functionName, context = TAG) {
block()
}
} catch (e: Exception) {
Expand All @@ -43,7 +43,7 @@ enum class ServiceQueue {
): T {
return withContext(coroutineContext) {
try {
measured(functionName) {
measured(label = functionName, context = TAG) {
block()
}
} catch (e: Exception) {
Expand All @@ -52,6 +52,10 @@ enum class ServiceQueue {
}
}
}

companion object {
private const val TAG = "ServiceQueue"
}
}

fun newSingleThreadDispatcher(id: String): ExecutorCoroutineDispatcher {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,11 @@ class NotifyPaymentReceivedHandler @Inject constructor(

private suspend fun buildNotificationContent(sats: Long): NotificationDetails {
val settings = settingsStore.data.first()
val title = context.getString(R.string.notification_received_title)
val title = context.getString(R.string.notification__received__title)
val body = if (settings.showNotificationDetails) {
formatNotificationAmount(sats, settings)
} else {
context.getString(R.string.notification_received_body_hidden)
context.getString(R.string.notification__received__body_hidden)
}
return NotificationDetails(title, body)
}
Expand All @@ -117,7 +117,7 @@ class NotifyPaymentReceivedHandler @Inject constructor(
}
} ?: "$BITCOIN_SYMBOL ${sats.formatToModernDisplay()}"

return context.getString(R.string.notification_received_body_amount, amountText)
return context.getString(R.string.notification__received__body_amount, amountText)
}

companion object {
Expand Down
18 changes: 10 additions & 8 deletions app/src/main/java/to/bitkit/ext/DateTime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,20 @@ fun Long.toDateUTC(): String {
return dateTime.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"))
}

fun Long.toLocalizedTimestamp(): String {
val uLocale = ULocale.forLocale(Locale.US)
val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, uLocale)
?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.US).format(Date(this))
return formatter.format(Date(this))
fun Long.toLocalizedTimestamp(locale: Locale = Locale.US): String {
val date = Date(this)
val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, ULocale.forLocale(locale))
?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", locale).format(date)
return formatter.format(date)
}

@Suppress("LongMethod")
@OptIn(ExperimentalTime::class)
fun Long.toRelativeTimeString(
locale: Locale = Locale.getDefault(),
clock: Clock = Clock.System,
style: RelativeDateTimeFormatter.Style = RelativeDateTimeFormatter.Style.LONG,
capitalizationContext: DisplayContext = DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE,
): String {
val now = nowMillis(clock)
val diffMillis = now - this
Expand All @@ -82,9 +84,9 @@ fun Long.toRelativeTimeString(
val formatter = RelativeDateTimeFormatter.getInstance(
uLocale,
numberFormat,
RelativeDateTimeFormatter.Style.LONG,
DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE,
) ?: return toLocalizedTimestamp()
style,
capitalizationContext,
) ?: return toLocalizedTimestamp(locale)

val seconds = diffMillis / Factor.MILLIS_TO_SECONDS
val minutes = seconds / Factor.SECONDS_TO_MINUTES
Expand Down
70 changes: 37 additions & 33 deletions app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ class WakeNodeWorker @AssistedInject constructor(
Logger.warn("Notification type is null, proceeding with node wake", context = TAG)
}

try {
measured(TAG) {
return runCatching {
measured(label = "doWork", context = TAG) {
lightningRepo.start(
walletIndex = 0,
timeout = timeout,
Expand All @@ -92,31 +92,35 @@ class WakeNodeWorker @AssistedInject constructor(
Logger.error("Missing orderId", context = TAG)
} else {
Logger.info("Open channel request for order $orderId", context = TAG)
blocktankRepo.openChannel(orderId).onFailure { e ->
Logger.error("Failed to open channel", e, context = TAG)
blocktankRepo.openChannel(orderId).onFailure {
Logger.error("Failed to open channel", it, context = TAG)
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_channel_open_failed_title),
body = e.message ?: appContext.getString(R.string.notification_unknown_error),
title = appContext.getString(R.string.notification__channel_open_failed_title),
body = it.message ?: appContext.getString(R.string.common__error_body),
)
deliver()
}
}
}
}
withTimeout(timeout) { deliverSignal.await() } // Stops node on timeout & avoids notification replay by OS
return Result.success()
} catch (e: Exception) {
val reason = e.message ?: appContext.getString(R.string.notification_unknown_error)
// Stops node on timeout & avoids notification replay by OS
withTimeout(timeout) { deliverSignal.await() }
}
.fold(
onSuccess = { Result.success() },
onFailure = { e ->
val reason = e.message ?: appContext.getString(R.string.common__error_body)

bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_lightning_error_title),
body = reason,
)
Logger.error("Lightning error", e, context = TAG)
deliver()
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification__lightning_error_title),
body = reason,
)
Logger.error("Lightning error", e, context = TAG)
deliver()

return Result.failure(workDataOf("Reason" to reason))
}
Result.failure(workDataOf("Reason" to reason))
}
)
}

/**
Expand All @@ -125,14 +129,14 @@ class WakeNodeWorker @AssistedInject constructor(
*/
private suspend fun handleLdkEvent(event: Event) {
val showDetails = settingsStore.data.first().showNotificationDetails
val hiddenBody = appContext.getString(R.string.notification_received_body_hidden)
val hiddenBody = appContext.getString(R.string.notification__received__body_hidden)
when (event) {
is Event.PaymentReceived -> onPaymentReceived(event, showDetails, hiddenBody)

is Event.ChannelPending -> {
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_channel_opened_title),
body = appContext.getString(R.string.notification_channel_pending_body),
title = appContext.getString(R.string.notification__channel_opened_title),
body = appContext.getString(R.string.notification__channel_pending_body),
)
// Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel
}
Expand All @@ -142,7 +146,7 @@ class WakeNodeWorker @AssistedInject constructor(

is Event.PaymentFailed -> {
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_payment_failed_title),
title = appContext.getString(R.string.notification__payment_failed_title),
body = "⚡ ${event.reason}",
)

Expand All @@ -158,18 +162,18 @@ class WakeNodeWorker @AssistedInject constructor(
private suspend fun onChannelClosed(event: Event.ChannelClosed) {
bestAttemptContent = when (notificationType) {
mutualClose -> NotificationDetails(
title = appContext.getString(R.string.notification_channel_closed_title),
body = appContext.getString(R.string.notification_channel_closed_mutual_body),
title = appContext.getString(R.string.notification__channel_closed__title),
body = appContext.getString(R.string.notification__channel_closed__mutual_body),
)

orderPaymentConfirmed -> NotificationDetails(
title = appContext.getString(R.string.notification_channel_open_bg_failed_title),
body = appContext.getString(R.string.notification_please_try_again_body),
title = appContext.getString(R.string.notification__channel_open_bg_failed_title),
body = appContext.getString(R.string.notification__please_try_again_body),
)

else -> NotificationDetails(
title = appContext.getString(R.string.notification_channel_closed_title),
body = appContext.getString(R.string.notification_channel_closed_reason_body, event.reason),
title = appContext.getString(R.string.notification__channel_closed__title),
body = appContext.getString(R.string.notification__channel_closed__reason_body, event.reason),
)
}

Expand All @@ -193,7 +197,7 @@ class WakeNodeWorker @AssistedInject constructor(
)
val content = if (showDetails) "$BITCOIN_SYMBOL $sats" else hiddenBody
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_received_title),
title = appContext.getString(R.string.notification__received__title),
body = content,
)
if (notificationType == incomingHtlc) {
Expand All @@ -206,10 +210,10 @@ class WakeNodeWorker @AssistedInject constructor(
showDetails: Boolean,
hiddenBody: String,
) {
val viaNewChannel = appContext.getString(R.string.notification_via_new_channel_body)
val viaNewChannel = appContext.getString(R.string.notification__received__body_channel)
if (notificationType == cjitPaymentArrived) {
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_received_title),
title = appContext.getString(R.string.notification__received__title),
body = viaNewChannel,
)

Expand All @@ -235,8 +239,8 @@ class WakeNodeWorker @AssistedInject constructor(
}
} else if (notificationType == orderPaymentConfirmed) {
bestAttemptContent = NotificationDetails(
title = appContext.getString(R.string.notification_channel_opened_title),
body = appContext.getString(R.string.notification_channel_ready_body),
title = appContext.getString(R.string.notification__channel_opened_title),
body = appContext.getString(R.string.notification__channel_ready_body),
)
}
deliver()
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/to/bitkit/models/ActivityBannerType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ enum class ActivityBannerType(
SPENDING(
color = Colors.Purple,
icon = R.drawable.ic_transfer,
title = R.string.activity_banner__transfer_in_progress
title = R.string.lightning__transfer_in_progress
),
SAVINGS(
color = Colors.Brand,
icon = R.drawable.ic_transfer,
title = R.string.activity_banner__transfer_in_progress
title = R.string.lightning__transfer_in_progress
)
}
21 changes: 11 additions & 10 deletions app/src/main/java/to/bitkit/models/NodeLifecycleState.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package to.bitkit.models

import android.content.Context
import to.bitkit.R

sealed class NodeLifecycleState {
data object Stopped : NodeLifecycleState()
data object Starting : NodeLifecycleState()
Expand All @@ -14,16 +17,14 @@ sealed class NodeLifecycleState {
fun isRunning() = this is Running
fun canRun() = this.isRunningOrStarting() || this is Initializing

// TODO add missing localized texts
val uiText: String
get() = when (this) {
is Stopped -> "Stopped"
is Starting -> "Starting"
is Running -> "Running"
is Stopping -> "Stopping"
is ErrorStarting -> "Error starting: ${cause.message}"
is Initializing -> "Setting up wallet..."
}
fun uiText(context: Context): String = when (this) {
is Stopped -> context.getString(R.string.other__node_stopped)
is Starting -> context.getString(R.string.other__node_starting)
is Running -> context.getString(R.string.other__node_running)
is Stopping -> context.getString(R.string.other__node_stopping)
is ErrorStarting -> context.getString(R.string.other__node_error_starting, cause.message ?: "")
is Initializing -> context.getString(R.string.other__node_initializing)
}

fun asHealth() = when (this) {
Running -> HealthState.READY
Expand Down
54 changes: 18 additions & 36 deletions app/src/main/java/to/bitkit/models/widget/ArticleModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,71 +2,53 @@ package to.bitkit.models.widget

import kotlinx.serialization.Serializable
import to.bitkit.data.dto.ArticleDTO
import to.bitkit.ext.toRelativeTimeString
import to.bitkit.utils.Logger
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
import java.util.Locale
import kotlin.time.ExperimentalTime

@Serializable
data class ArticleModel(
val title: String,
val timeAgo: String,
val link: String,
val publisher: String
val publisher: String,
)

fun ArticleDTO.toArticleModel() = ArticleModel(
title = this.title,
timeAgo = timeAgo(this.publishedDate),
link = this.link,
publisher = this.publisher.title
publisher = this.publisher.title,
)

/**
* Converts a date string to a human-readable time ago format
* @param dateString Date string in format "EEE, dd MMM yyyy HH:mm:ss Z"
* @return Human-readable time difference (e.g. "5 hours ago")
*/
private const val TAG = "ArticleModel"

@OptIn(ExperimentalTime::class)
private fun timeAgo(dateString: String): String {
return try {
return runCatching {
val formatters = listOf(
DateTimeFormatter.RFC_1123_DATE_TIME, // Handles "EEE, dd MMM yyyy HH:mm:ss zzz" (like GMT)
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) // Handles "+0000"
DateTimeFormatter.RFC_1123_DATE_TIME,
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH)
)

var parsedDateTime: OffsetDateTime? = null
for (formatter in formatters) {
try {
parsedDateTime = OffsetDateTime.parse(dateString, formatter)
break // Successfully parsed, stop trying other formatters
} catch (e: DateTimeParseException) {
// Continue to the next formatter if this one fails
break
} catch (_: DateTimeParseException) {
// Continue to the next formatter
}
}

if (parsedDateTime == null) {
Logger.debug("Failed to parse date: Unparseable date: $dateString")
return ""
}

val now = OffsetDateTime.now()
requireNotNull(parsedDateTime) { "Unparseable date: '$dateString'" }

val diffMinutes = ChronoUnit.MINUTES.between(parsedDateTime, now)
val diffHours = ChronoUnit.HOURS.between(parsedDateTime, now)
val diffDays = ChronoUnit.DAYS.between(parsedDateTime, now)
val diffMonths = ChronoUnit.MONTHS.between(parsedDateTime, now)

return when {
diffMinutes < 1 -> "just now"
diffMinutes < 60 -> "$diffMinutes minutes ago"
diffHours < 24 -> "$diffHours hours ago"
diffDays < 30 -> "$diffDays days ago" // Approximate for months
else -> "$diffMonths months ago"
}
} catch (e: Exception) {
Logger.warn("An unexpected error occurred while parsing date: ${e.message}")
""
}
parsedDateTime.toInstant().toEpochMilli().toRelativeTimeString()
}.onFailure {
Logger.warn("Failed to parse date: ${it.message}", it, context = TAG)
}.getOrDefault("")
}
Loading
Loading