Skip to content
Draft
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
21 changes: 21 additions & 0 deletions app/src/main/java/to/bitkit/ext/Activities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,29 @@ import com.synonym.bitkitcore.LightningActivity
import com.synonym.bitkitcore.OnchainActivity
import com.synonym.bitkitcore.PaymentState
import com.synonym.bitkitcore.PaymentType
import to.bitkit.models.ActivityWalletType

/**
* Wallet id of the local Bitkit wallet. Mirrors Bitkit Core's `getDefaultWalletId()` (Rust
* `DEFAULT_WALLET_ID`); kept local so the value is available without a JNI call. Hardware wallets
* use their own derived id instead.
*/
val DEFAULT_WALLET_ID: String get() = ActivityWalletType.BITKIT.id

fun Activity.rawId(): String = when (this) {
is Activity.Lightning -> v1.id
is Activity.Onchain -> v1.id
}

fun Activity.walletId(): String = when (this) {
is Activity.Lightning -> v1.walletId
is Activity.Onchain -> v1.walletId
}

fun Activity.scopedId(): String = "${walletId()}:${rawId()}"

fun Activity.isHardwareWalletActivity(): Boolean = ActivityWalletType.TREZOR.owns(walletId())

fun Activity.txType(): PaymentType = when (this) {
is Activity.Lightning -> v1.txType
is Activity.Onchain -> v1.txType
Expand Down Expand Up @@ -107,7 +124,9 @@ fun LightningActivity.Companion.create(
createdAt: ULong? = timestamp,
updatedAt: ULong? = createdAt,
seenAt: ULong? = null,
walletId: String = DEFAULT_WALLET_ID,
) = LightningActivity(
walletId = walletId,
id = id,
txType = txType,
status = status,
Expand Down Expand Up @@ -145,7 +164,9 @@ fun OnchainActivity.Companion.create(
createdAt: ULong? = timestamp,
updatedAt: ULong? = createdAt,
seenAt: ULong? = null,
walletId: String = DEFAULT_WALLET_ID,
) = OnchainActivity(
walletId = walletId,
id = id,
txType = txType,
txId = txId,
Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/to/bitkit/models/ActivityWalletType.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package to.bitkit.models

import java.security.MessageDigest
import java.util.Locale

enum class ActivityWalletType {
BITKIT,
TREZOR,
;

val id: String
get() = name.lowercase(Locale.US)

fun owns(walletId: String): Boolean = walletId == id || walletId.startsWith(prefix)

fun scopedId(value: String): String = "$prefix$value"

fun deriveId(keys: Collection<String>): String {
val normalizedKeys = keys.filter { it.isNotBlank() }
if (normalizedKeys.isEmpty()) return ""

val hash = MessageDigest.getInstance("SHA-256")
.digest(normalizedKeys.sorted().joinToString("\n").toByteArray(Charsets.UTF_8))
return scopedId(hash.joinToString("") { "%02x".format(it) })
}

private val prefix: String
get() = "$id:"
}
1 change: 1 addition & 0 deletions app/src/main/java/to/bitkit/models/HardwareWallet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ data class HwWalletBalance(
data class HwWalletReceivedTx(
val txid: String,
val sats: ULong,
val walletId: String,
)

sealed interface HwFundingAccount {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ data class NewTransactionSheetDetails(
val direction: NewTransactionSheetDirection,
val paymentHashOrTxId: String? = null,
val activityId: String? = null,
val activityWalletId: String? = null,
val sats: Long = 0,
val isLoadingDetails: Boolean = false,
) {
Expand Down
94 changes: 60 additions & 34 deletions app/src/main/java/to/bitkit/repositories/ActivityRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import to.bitkit.data.CacheStore
import to.bitkit.data.dto.PendingBoostActivity
import to.bitkit.di.BgDispatcher
import to.bitkit.di.IoDispatcher
import to.bitkit.ext.DEFAULT_WALLET_ID
import to.bitkit.ext.amountOnClose
import to.bitkit.ext.contact
import to.bitkit.ext.isReplacedSentTransaction
Expand Down Expand Up @@ -213,23 +214,31 @@ class ActivityRepo @Inject constructor(
notifyActivitiesChanged()
}

suspend fun syncHardwareOnchainActivity(activity: OnchainActivity): Result<Unit> = withContext(bgDispatcher) {
/**
* Persists the wallet-scoped activities and transaction details a hardware-wallet watcher
* emits, so hardware transactions become first-class Bitkit Core activities (tags, inputs/outputs).
*/
suspend fun persistHardwareActivities(
activities: List<Activity>,
transactionDetails: List<BitkitCoreTransactionDetails>,
): Result<Unit> = withContext(bgDispatcher) {
runCatching {
val existing = coreService.activity.getOnchainActivityByTxId(activity.txId) ?: return@runCatching
val confirmTimestamp = existing.confirmTimestamp ?: activity.confirmTimestamp ?: activity.timestamp
.takeIf { activity.confirmed }
val updated = existing.copy(
confirmed = existing.confirmed || activity.confirmed,
confirmTimestamp = confirmTimestamp,
doesExist = if (activity.confirmed) true else existing.doesExist,
fee = if (existing.fee == 0uL && activity.fee > 0uL) activity.fee else existing.fee,
updatedAt = maxOf(existing.updatedAt ?: 0uL, activity.updatedAt ?: activity.timestamp),
)
if (updated == existing) return@runCatching
coreService.activity.update(existing.id, Activity.Onchain(updated))
if (activities.isNotEmpty()) coreService.activity.upsertList(activities)
if (transactionDetails.isNotEmpty()) coreService.activity.upsertTransactionDetailsList(transactionDetails)
if (activities.isNotEmpty() || transactionDetails.isNotEmpty()) notifyActivitiesChanged()
}.onFailure {
Logger.error("Failed to persist hardware activities", it, context = TAG)
}
}

/** Removes all activity, details and tag metadata scoped to a hardware wallet's id. */
suspend fun deleteActivitiesForWallet(walletId: String): Result<Unit> = withContext(bgDispatcher) {
runCatching {
val deleted = coreService.activity.deleteByWalletId(walletId)
notifyActivitiesChanged()
Logger.info("Deleted '$deleted' activities for hardware wallet '$walletId'", context = TAG)
}.onFailure {
Logger.error("Failed to sync hardware activity '${activity.txId}'", it, context = TAG)
Logger.error("Failed to delete activities for hardware wallet '$walletId'", it, context = TAG)
}
}

Expand Down Expand Up @@ -257,22 +266,25 @@ class ActivityRepo @Inject constructor(
return coreService.activity.shouldShowReceivedSheet(txid, value)
}

suspend fun isActivitySeen(activityId: String): Boolean {
return coreService.activity.isActivitySeen(activityId)
suspend fun isActivitySeen(activityId: String, walletId: String? = null): Boolean {
return coreService.activity.isActivitySeen(activityId, walletId)
}

suspend fun markActivityAsSeen(activityId: String) {
coreService.activity.markActivityAsSeen(activityId)
suspend fun markActivityAsSeen(activityId: String, walletId: String? = null) {
coreService.activity.markActivityAsSeen(activityId, walletId = walletId)
notifyActivitiesChanged()
}

suspend fun markOnchainActivityAsSeen(txid: String) {
coreService.activity.markOnchainActivityAsSeen(txid)
suspend fun markOnchainActivityAsSeen(txid: String, walletId: String? = null) {
coreService.activity.markOnchainActivityAsSeen(txid, walletId = walletId)
notifyActivitiesChanged()
}

suspend fun getTransactionDetails(txid: String): Result<BitkitCoreTransactionDetails?> = runCatching {
coreService.activity.getTransactionDetails(txid)
suspend fun getTransactionDetails(
txid: String,
walletId: String? = null,
): Result<BitkitCoreTransactionDetails?> = runCatching {
coreService.activity.getTransactionDetails(txid, walletId)
}

suspend fun getBoostTxDoesExist(boostTxIds: List<String>): Map<String, Boolean> {
Expand Down Expand Up @@ -327,6 +339,7 @@ class ActivityRepo @Inject constructor(
}

suspend fun getActivities(
walletId: String? = null,
filter: ActivityFilter? = null,
txType: PaymentType? = null,
tags: List<String>? = null,
Expand All @@ -337,7 +350,7 @@ class ActivityRepo @Inject constructor(
sortDirection: SortDirection? = null,
): Result<List<Activity>> = withContext(bgDispatcher) {
runCatching {
coreService.activity.get(filter, txType, tags, search, minDate, maxDate, limit, sortDirection)
coreService.activity.get(walletId, filter, txType, tags, search, minDate, maxDate, limit, sortDirection)
}.onFailure {
Logger.error(
"getActivities error. Parameters:" +
Expand All @@ -355,11 +368,11 @@ class ActivityRepo @Inject constructor(
}
}

suspend fun getActivity(id: String): Result<Activity?> = withContext(bgDispatcher) {
suspend fun getActivity(id: String, walletId: String? = null): Result<Activity?> = withContext(bgDispatcher) {
runCatching {
coreService.activity.getActivity(id)
coreService.activity.getActivity(id, walletId)
}.onFailure {
Logger.error("getActivity error for ID: $id", it, context = TAG)
Logger.error("Failed to get activity '$id'", it, context = TAG)
}
}

Expand Down Expand Up @@ -654,6 +667,7 @@ class ActivityRepo @Inject constructor(
insertActivity(
Activity.Lightning(
LightningActivity(
walletId = DEFAULT_WALLET_ID,
id = id,
txType = PaymentType.RECEIVED,
status = PaymentState.SUCCEEDED,
Expand Down Expand Up @@ -684,15 +698,18 @@ class ActivityRepo @Inject constructor(
suspend fun addTagsToActivity(
activityId: String,
tags: List<String>,
walletId: String? = null,
): Result<Unit> = withContext(bgDispatcher) {
runCatching {
checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" }
checkNotNull(coreService.activity.getActivity(activityId, walletId)) {
"Activity with ID $activityId not found"
}

val existingTags = coreService.activity.tags(activityId)
val existingTags = coreService.activity.tags(activityId, walletId)
val newTags = tags.filter { it.isNotBlank() && it !in existingTags }

if (newTags.isNotEmpty()) {
coreService.activity.appendTags(activityId, newTags).getOrThrow()
coreService.activity.appendTags(activityId, newTags, walletId).getOrThrow()
notifyActivitiesChanged()
Logger.info("Added ${newTags.size} new tags to activity $activityId", context = TAG)
} else {
Expand Down Expand Up @@ -726,12 +743,18 @@ class ActivityRepo @Inject constructor(
/**
* Removes tags from an activity
*/
suspend fun removeTagsFromActivity(activityId: String, tags: List<String>): Result<Unit> =
suspend fun removeTagsFromActivity(
activityId: String,
tags: List<String>,
walletId: String? = null,
): Result<Unit> =
withContext(bgDispatcher) {
runCatching {
checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" }
checkNotNull(coreService.activity.getActivity(activityId, walletId)) {
"Activity with ID $activityId not found"
}

coreService.activity.dropTags(activityId, tags)
coreService.activity.dropTags(activityId, tags, walletId)
notifyActivitiesChanged()
Logger.info("Removed ${tags.size} tags from activity $activityId", context = TAG)
}.onFailure {
Expand All @@ -742,9 +765,12 @@ class ActivityRepo @Inject constructor(
/**
* Gets all tags for an activity
*/
suspend fun getActivityTags(activityId: String): Result<List<String>> = withContext(bgDispatcher) {
suspend fun getActivityTags(
activityId: String,
walletId: String? = null,
): Result<List<String>> = withContext(bgDispatcher) {
runCatching {
coreService.activity.tags(activityId)
coreService.activity.tags(activityId, walletId)
}.onFailure {
Logger.error("getActivityTags error for activity $activityId", it, context = TAG)
}
Expand Down
Loading
Loading