Remove phoenix code
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
package fr.acinq.phoenix.data
|
||||
|
||||
import fr.acinq.lightning.io.TcpSocket
|
||||
import fr.acinq.lightning.utils.ServerAddress
|
||||
|
||||
actual fun platformElectrumRegtestConf(): ServerAddress = ServerAddress(host = "127.0.0.1", port = 51002, TcpSocket.TLS.DISABLED)
|
||||
@@ -1,319 +0,0 @@
|
||||
package fr.acinq.phoenix.db
|
||||
|
||||
import app.cash.sqldelight.Transacter
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import fr.acinq.lightning.utils.UUID
|
||||
import fr.acinq.lightning.utils.currentTimestampMillis
|
||||
import fr.acinq.phoenix.data.ContactInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class CloudKitContactsDb(
|
||||
private val paymentsDb: SqlitePaymentsDb
|
||||
): CoroutineScope by MainScope() {
|
||||
|
||||
private val db: Transacter = paymentsDb.database
|
||||
private val queries = paymentsDb.database.cloudKitContactsQueries
|
||||
|
||||
/**
|
||||
* Provides a flow of the count of items within the cloudkit_contacts_queue table.
|
||||
*/
|
||||
private val _queueCount = MutableStateFlow<Long>(0)
|
||||
val queueCount: StateFlow<Long> = _queueCount.asStateFlow()
|
||||
|
||||
data class MetadataRow(
|
||||
val recordCreation: Long,
|
||||
val recordBlob: ByteArray
|
||||
)
|
||||
|
||||
data class MissingItem(
|
||||
val contactId: UUID,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
data class FetchQueueBatchResult(
|
||||
|
||||
// The fetched rowid values from the `cloudkit_contacts_queue` table
|
||||
val rowids: List<Long>,
|
||||
|
||||
// Maps `cloudkit_contacts_queue.rowid` to the corresponding ContactId.
|
||||
// If missing from the map, then the `cloudkit_contacts_queue` row was
|
||||
// malformed or unrecognized.
|
||||
val rowidMap: Map<Long, UUID>,
|
||||
|
||||
// Maps to the contact information in the database.
|
||||
// If missing from the map, then the contacts has been deleted from the database.
|
||||
val rowMap: Map<UUID, ContactInfo>,
|
||||
|
||||
// Maps to `cloudkit_contacts_metadata.ckrecord_info`.
|
||||
// If missing from the map, then then record doesn't exist in the database.
|
||||
val metadataMap: Map<UUID, ByteArray>,
|
||||
)
|
||||
|
||||
init {
|
||||
// N.B.: There appears to be a subtle bug in SQLDelight's
|
||||
// `.asFlow().mapToX()`, as described here:
|
||||
// https://github.com/ACINQ/phoenix/pull/415
|
||||
launch {
|
||||
queries.fetchQueueCount()
|
||||
.asFlow()
|
||||
.map {
|
||||
withContext(Dispatchers.Default) {
|
||||
db.transactionWithResult {
|
||||
it.executeAsOne()
|
||||
}
|
||||
}
|
||||
}
|
||||
.collect { count ->
|
||||
_queueCount.value = count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult {
|
||||
return withContext(Dispatchers.Default) {
|
||||
|
||||
val rowids = mutableListOf<Long>()
|
||||
val rowidMap = mutableMapOf<Long, UUID>()
|
||||
val rowMap = mutableMapOf<UUID, ContactInfo>()
|
||||
val metadataMap = mutableMapOf<UUID, ByteArray>()
|
||||
|
||||
db.transaction {
|
||||
|
||||
// Step 1 of 3:
|
||||
// Fetch the rows from the `cloudkit_contacts_queue` batch.
|
||||
// We are fetching the next/oldest X rows from the queue.
|
||||
|
||||
val batch = queries.fetchQueueBatch(limit).executeAsList()
|
||||
|
||||
// Step 2 of 3:
|
||||
// Process the batch, and fill out the `rowids` & `rowidMap` variable.
|
||||
|
||||
batch.forEach { row ->
|
||||
rowids.add(row.rowid)
|
||||
try {
|
||||
val contactId = row.id
|
||||
rowidMap[row.rowid] = UUID.fromString(contactId)
|
||||
} catch (e: Exception) {
|
||||
// UUID appears to be malformed within the database.
|
||||
// Nothing we can do here - but let's at least not crash.
|
||||
}
|
||||
} // </batch.forEach>
|
||||
|
||||
// Remember: there could be duplicates
|
||||
val uniqueContactIds = rowidMap.values.toSet()
|
||||
|
||||
// Step 3 of 3:
|
||||
// Fetch the corresponding contact info from the database.
|
||||
|
||||
uniqueContactIds.forEach { contactId ->
|
||||
if (paymentsDb.contacts.contactQueries.existsContact(contactId)) {
|
||||
// appDb.contactQueries.getContact() throws if the contact
|
||||
// doesn't exist in database.
|
||||
paymentsDb.contacts.contactQueries.getContact(contactId)?.let {
|
||||
rowMap[contactId] = it
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Step 4 of 5:
|
||||
// Fetch the corresponding `cloudkit_contacts_metadata.ckrecord_info`
|
||||
|
||||
uniqueContactIds.forEach { contactId ->
|
||||
queries.fetchMetadata(
|
||||
id = contactId.toString()
|
||||
).executeAsOneOrNull()?.let { row ->
|
||||
metadataMap[contactId] = row.record_blob
|
||||
}
|
||||
}
|
||||
|
||||
} // </database.transaction>
|
||||
|
||||
FetchQueueBatchResult(
|
||||
rowids = rowids,
|
||||
rowidMap = rowidMap,
|
||||
rowMap = rowMap,
|
||||
metadataMap = metadataMap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateRows(
|
||||
deleteFromQueue: List<Long>,
|
||||
deleteFromMetadata: List<UUID>,
|
||||
updateMetadata: Map<UUID, MetadataRow>
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
db.transaction {
|
||||
|
||||
deleteFromQueue.forEach { rowid ->
|
||||
queries.deleteFromQueue(rowid)
|
||||
}
|
||||
|
||||
deleteFromMetadata.forEach { contactId ->
|
||||
queries.deleteMetadata(
|
||||
id = contactId.toString()
|
||||
)
|
||||
}
|
||||
|
||||
updateMetadata.forEach { (contactId, row) ->
|
||||
val rowExists = queries.existsMetadata(
|
||||
id = contactId.toString()
|
||||
).executeAsOne() > 0
|
||||
if (rowExists) {
|
||||
queries.updateMetadata(
|
||||
record_blob = row.recordBlob,
|
||||
id = contactId.toString()
|
||||
)
|
||||
} else {
|
||||
queries.addMetadata(
|
||||
id = contactId.toString(),
|
||||
record_creation = row.recordCreation,
|
||||
record_blob = row.recordBlob
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchOldestCreation(): Long? {
|
||||
return withContext(Dispatchers.Default) {
|
||||
val row = queries.fetchOldestCreation_Contacts().executeAsOneOrNull()
|
||||
row?.record_creation
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateRows(
|
||||
downloadedContacts: List<ContactInfo>,
|
||||
updateMetadata: Map<UUID, MetadataRow>
|
||||
) {
|
||||
// We are seeing crashes when accessing the values within the List<PaymentRow>.
|
||||
// Perhaps because the List was created in Swift ?
|
||||
// The workaround seems to be to copy the list here,
|
||||
// or otherwise process it outside of the `withContext` below.
|
||||
val contacts = downloadedContacts.map { it.copy() }
|
||||
|
||||
withContext(Dispatchers.Default) {
|
||||
val contactQueries = paymentsDb.contacts.contactQueries
|
||||
|
||||
db.transaction {
|
||||
for (contact in contacts) {
|
||||
|
||||
val rowExists = queries.existsMetadata(
|
||||
id = contact.id.toString()
|
||||
).executeAsOne() > 0
|
||||
if (!rowExists) {
|
||||
contactQueries.saveContact(contact, notify = false)
|
||||
}
|
||||
}
|
||||
|
||||
for ((contactId, row) in updateMetadata) {
|
||||
val rowExists = queries.existsMetadata(
|
||||
id = contactId.toString()
|
||||
).executeAsOne() > 0
|
||||
|
||||
if (rowExists) {
|
||||
queries.updateMetadata(
|
||||
record_blob = row.recordBlob,
|
||||
id = contactId.toString()
|
||||
)
|
||||
} else {
|
||||
queries.addMetadata(
|
||||
id = contactId.toString(),
|
||||
record_creation = row.recordCreation,
|
||||
record_blob = row.recordBlob
|
||||
)
|
||||
}
|
||||
} // </cloudkit_contacts_metadata table>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun enqueueMissingItems() {
|
||||
withContext(Dispatchers.Default) {
|
||||
val rawContactQueries = paymentsDb.database.contactsQueries
|
||||
|
||||
db.transaction {
|
||||
|
||||
// Step 1 of 3:
|
||||
// Fetch list of contact ID's that are already represented in the cloud.
|
||||
|
||||
val cloudContactIds = mutableSetOf<UUID>()
|
||||
queries.scanMetadata().executeAsList().forEach { id ->
|
||||
try {
|
||||
val contactId = UUID.fromString(id)
|
||||
cloudContactIds.add(contactId)
|
||||
} catch (e: Exception) {
|
||||
// UUID appears to be malformed within the database.
|
||||
// Nothing we can do here - but let's at least not crash.
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2 of 3:
|
||||
// Scan local contact ID's, looking to see if any are missing from the cloud.
|
||||
|
||||
val missing = mutableListOf<MissingItem>()
|
||||
rawContactQueries.scanContacts().executeAsList().forEach { row ->
|
||||
try {
|
||||
if (!cloudContactIds.contains(row.id)) {
|
||||
missing.add(MissingItem(
|
||||
contactId = row.id,
|
||||
timestamp = row.created_at
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// UUID appears to be malformed within the database.
|
||||
// Nothing we can do here - but let's at least not crash.
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 of 3:
|
||||
// Add any missing items to the queue.
|
||||
//
|
||||
// But in what order do we want to upload them to the cloud ?
|
||||
//
|
||||
// We will choose to upload the OLDEST item first.
|
||||
// This matches how they normally would have been uploaded.
|
||||
// Also, when a user restores their wallet (e.g. on a new phone),
|
||||
// we always want to download the newest contacts first.
|
||||
// And this assumes the newest items in the cloud are the newest contacts.
|
||||
//
|
||||
// Since items are uploaded in FIFO order,
|
||||
// we just need to make the oldest item have the
|
||||
// smallest `date_added` value.
|
||||
|
||||
missing.sortByDescending { it.timestamp }
|
||||
|
||||
// The list is now sorted in descending order.
|
||||
// Which means the newest item is at index 0,
|
||||
// and the oldest item is at index <last>.
|
||||
|
||||
val now = currentTimestampMillis()
|
||||
missing.forEachIndexed { idx, item ->
|
||||
queries.addToQueue(
|
||||
id = item.contactId.toString(),
|
||||
date_added = now - idx
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearDatabaseTables() {
|
||||
withContext(Dispatchers.Default) {
|
||||
db.transaction {
|
||||
queries.deleteAllFromMetadata()
|
||||
queries.deleteAllFromQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package fr.acinq.phoenix.db
|
||||
|
||||
import fr.acinq.phoenix.db.payments.CloudKitInterface
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
|
||||
class CloudKitDb(
|
||||
appDb: SqliteAppDb,
|
||||
paymentsDb: SqlitePaymentsDb
|
||||
): CloudKitInterface, CoroutineScope by MainScope() {
|
||||
|
||||
val contacts = CloudKitContactsDb(paymentsDb)
|
||||
val payments = CloudKitPaymentsDb(paymentsDb)
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
package fr.acinq.phoenix.db
|
||||
|
||||
import app.cash.sqldelight.Transacter
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import fr.acinq.lightning.db.IncomingPayment
|
||||
import fr.acinq.lightning.db.OutgoingPayment
|
||||
import fr.acinq.lightning.db.WalletPayment
|
||||
import fr.acinq.lightning.utils.UUID
|
||||
import fr.acinq.lightning.utils.currentTimestampMillis
|
||||
import fr.acinq.phoenix.data.WalletPaymentInfo
|
||||
import fr.acinq.phoenix.data.WalletPaymentMetadata
|
||||
import fr.acinq.phoenix.db.payments.SqliteIncomingPaymentsDb
|
||||
import fr.acinq.phoenix.db.payments.SqliteOutgoingPaymentsDb
|
||||
import fr.acinq.phoenix.db.payments.WalletPaymentMetadataRow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class CloudKitPaymentsDb(
|
||||
private val paymentsDb: SqlitePaymentsDb
|
||||
): CoroutineScope by MainScope() {
|
||||
|
||||
private val db: Transacter = paymentsDb.database
|
||||
private val queries = paymentsDb.database.cloudKitPaymentsQueries
|
||||
|
||||
/**
|
||||
* Provides a flow of the count of items within the cloudkit_payments_queue table.
|
||||
*/
|
||||
private val _queueCount = MutableStateFlow<Long>(0)
|
||||
val queueCount: StateFlow<Long> = _queueCount.asStateFlow()
|
||||
|
||||
data class MetadataRow(
|
||||
val unpaddedSize: Long,
|
||||
val recordCreation: Long,
|
||||
val recordBlob: ByteArray
|
||||
)
|
||||
|
||||
data class FetchQueueBatchResult(
|
||||
|
||||
// The fetched rowid values from the `cloudkit_payments_queue` table
|
||||
val rowids: List<Long>,
|
||||
|
||||
// Maps `cloudkit_payments_queue.rowid` to the corresponding PaymentRowId.
|
||||
// If missing from the map, then the `cloudkit_payments_queue` row was
|
||||
// malformed or unrecognized.
|
||||
val rowidMap: Map<Long, UUID>,
|
||||
|
||||
// Maps to the fetch payment information in the database.
|
||||
// If missing from the map, then the payment has been deleted from the database.
|
||||
val rowMap: Map<UUID, WalletPaymentInfo>,
|
||||
|
||||
// Maps to `cloudkit_payments_metadata.ckrecord_info`.
|
||||
// If missing from the map, then then record doesn't exist in the database.
|
||||
val metadataMap: Map<UUID, ByteArray>
|
||||
)
|
||||
|
||||
init {
|
||||
// N.B.: There appears to be a subtle bug in SQLDelight's
|
||||
// `.asFlow().mapToX()`, as described here:
|
||||
// https://github.com/ACINQ/phoenix/pull/415
|
||||
launch {
|
||||
queries.fetchQueueCount()
|
||||
.asFlow()
|
||||
.map {
|
||||
withContext(Dispatchers.Default) {
|
||||
db.transactionWithResult {
|
||||
it.executeAsOne()
|
||||
}
|
||||
}
|
||||
}
|
||||
.collect { count ->
|
||||
_queueCount.value = count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchQueueBatch(limit: Long): FetchQueueBatchResult {
|
||||
return withContext(Dispatchers.Default) {
|
||||
|
||||
val ckQueries = paymentsDb.database.cloudKitPaymentsQueries
|
||||
|
||||
val rowids = mutableListOf<Long>()
|
||||
val rowidMap = mutableMapOf<Long, UUID>()
|
||||
val rowMap = mutableMapOf<UUID, WalletPaymentInfo>()
|
||||
val metadataMap = mutableMapOf<UUID, ByteArray>()
|
||||
|
||||
db.transaction {
|
||||
|
||||
// Step 1 of 4:
|
||||
// Fetch the rows from the `cloudkit_payments_queue` batch.
|
||||
// We are fetching the next/oldest X rows from the queue.
|
||||
|
||||
val batch = ckQueries.fetchQueueBatch(limit).executeAsList()
|
||||
|
||||
// Step 2 of 4:
|
||||
// Process the batch, and fill out the `rowids` & `rowidMap` variable.
|
||||
|
||||
batch.forEach { row ->
|
||||
rowids.add(row.rowid)
|
||||
rowidMap[row.rowid] = row.id
|
||||
} // </batch.forEach>
|
||||
|
||||
// Remember: there could be duplicates
|
||||
val uniquePaymentIds = rowidMap.values.toSet()
|
||||
|
||||
// Step 3 of 4:
|
||||
// Fetch the corresponding payment info from the database.
|
||||
// In order to optimize disk access, we fetch from 1 table at a time.
|
||||
|
||||
val metadataPlaceholder = WalletPaymentMetadata()
|
||||
|
||||
uniquePaymentIds.forEach { paymentId ->
|
||||
paymentsDb._getPayment(paymentId)?.let { pair ->
|
||||
rowMap[paymentId] = WalletPaymentInfo(
|
||||
payment = pair.first,
|
||||
metadata = metadataPlaceholder,
|
||||
contact = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
uniquePaymentIds.forEach { paymentId ->
|
||||
paymentsDb.metadataQueries.get(paymentId)?.let { metadata ->
|
||||
rowMap[paymentId]?.let {
|
||||
rowMap[paymentId] = it.copy(
|
||||
metadata = metadata,
|
||||
contact = null
|
||||
)
|
||||
}
|
||||
}
|
||||
} // </payments_metadata>
|
||||
|
||||
// Step 4 of 4:
|
||||
// Fetch the corresponding `cloudkit_payments_metadata.ckrecord_info`
|
||||
|
||||
uniquePaymentIds.forEach { paymentId ->
|
||||
ckQueries.fetchMetadata(paymentId).executeAsOneOrNull()?.let { row ->
|
||||
metadataMap[paymentId] = row.record_blob
|
||||
}
|
||||
}
|
||||
|
||||
} // </db.transaction>
|
||||
|
||||
FetchQueueBatchResult(
|
||||
rowids = rowids,
|
||||
rowidMap = rowidMap,
|
||||
rowMap = rowMap,
|
||||
metadataMap = metadataMap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateRows(
|
||||
deleteFromQueue: List<Long>,
|
||||
deleteFromMetadata: List<UUID>,
|
||||
updateMetadata: Map<UUID, MetadataRow>
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
db.transaction {
|
||||
|
||||
deleteFromQueue.forEach { rowid ->
|
||||
queries.deleteFromQueue(rowid)
|
||||
}
|
||||
|
||||
deleteFromMetadata.forEach { paymentId ->
|
||||
queries.deleteMetadata(paymentId)
|
||||
}
|
||||
|
||||
updateMetadata.forEach { (paymentId, row) ->
|
||||
val rowExists = queries.existsMetadata(paymentId).executeAsOne() > 0
|
||||
if (rowExists) {
|
||||
queries.updateMetadata(
|
||||
unpadded_size = row.unpaddedSize,
|
||||
record_blob = row.recordBlob,
|
||||
id = paymentId
|
||||
)
|
||||
} else {
|
||||
queries.addMetadata(
|
||||
id = paymentId,
|
||||
unpadded_size = row.unpaddedSize,
|
||||
record_creation = row.recordCreation,
|
||||
record_blob = row.recordBlob
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchMetadata(
|
||||
id: UUID
|
||||
): ByteArray? = withContext(Dispatchers.Default) {
|
||||
|
||||
val row = queries.fetchMetadata(id = id).executeAsOneOrNull()
|
||||
row?.record_blob
|
||||
}
|
||||
|
||||
suspend fun fetchOldestCreation(): Long? = withContext(Dispatchers.Default) {
|
||||
|
||||
val row = queries.fetchOldestCreation().executeAsOneOrNull()
|
||||
row?.record_creation
|
||||
}
|
||||
|
||||
suspend fun updateRows(
|
||||
downloadedPayments: List<WalletPayment>,
|
||||
downloadedPaymentsMetadata: Map<UUID, WalletPaymentMetadataRow>,
|
||||
updateMetadata: Map<UUID, MetadataRow>
|
||||
) {
|
||||
withContext(Dispatchers.Default) {
|
||||
|
||||
val inQueries = paymentsDb.database.paymentsIncomingQueries
|
||||
val outQueries = paymentsDb.database.paymentsOutgoingQueries
|
||||
val ckQueries = paymentsDb.database.cloudKitPaymentsQueries
|
||||
val metaQueries = paymentsDb.database.paymentsMetadataQueries
|
||||
|
||||
val incomingPaymentsDb = SqliteIncomingPaymentsDb(paymentsDb.database, paymentsDb.paymentMetadataQueue)
|
||||
val outgoingPaymentsDb = SqliteOutgoingPaymentsDb(paymentsDb.database, paymentsDb.paymentMetadataQueue)
|
||||
|
||||
db.transaction {
|
||||
|
||||
downloadedPayments.forEach { payment ->
|
||||
|
||||
val paymentId: UUID = payment.id
|
||||
if (payment is IncomingPayment) {
|
||||
val existing = inQueries.get(paymentId).executeAsOneOrNull()
|
||||
if (existing == null) {
|
||||
incomingPaymentsDb._addIncomingPayment(payment, metadata = null, notify = false)
|
||||
}
|
||||
} else if (payment is OutgoingPayment) {
|
||||
val existing = outQueries.get(paymentId).executeAsOneOrNull()
|
||||
if (existing == null) {
|
||||
outgoingPaymentsDb._addOutgoingPayment(payment, metadata = null, notify = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
downloadedPaymentsMetadata.forEach { (paymentId, row) ->
|
||||
val rowExists = metaQueries.hasMetadata(paymentId).executeAsOne() > 0
|
||||
if (!rowExists) {
|
||||
metaQueries.addMetadata(
|
||||
payment_id = paymentId,
|
||||
lnurl_base_type = row.lnurl_base?.first,
|
||||
lnurl_base_blob = row.lnurl_base?.second,
|
||||
lnurl_metadata_type = row.lnurl_metadata?.first,
|
||||
lnurl_metadata_blob = row.lnurl_metadata?.second,
|
||||
lnurl_successAction_type = row.lnurl_successAction?.first,
|
||||
lnurl_successAction_blob = row.lnurl_successAction?.second,
|
||||
lnurl_description = row.lnurl_description,
|
||||
user_description = row.user_description,
|
||||
user_notes = row.user_notes,
|
||||
modified_at = row.modified_at,
|
||||
original_fiat_type = row.original_fiat?.first,
|
||||
original_fiat_rate = row.original_fiat?.second,
|
||||
lightning_address = row.lightning_address
|
||||
)
|
||||
}
|
||||
} // </payments_metadata table>
|
||||
|
||||
updateMetadata.forEach { (paymentId, row) ->
|
||||
val rowExists = ckQueries.existsMetadata(paymentId).executeAsOne() > 0
|
||||
if (rowExists) {
|
||||
ckQueries.updateMetadata(
|
||||
unpadded_size = row.unpaddedSize,
|
||||
record_blob = row.recordBlob,
|
||||
id = paymentId
|
||||
)
|
||||
} else {
|
||||
ckQueries.addMetadata(
|
||||
id = paymentId,
|
||||
unpadded_size = row.unpaddedSize,
|
||||
record_creation = row.recordCreation,
|
||||
record_blob = row.recordBlob
|
||||
)
|
||||
}
|
||||
} // </cloudkit_payments_metadata table>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun enqueueOutdatedItems() = withContext(Dispatchers.Default) {
|
||||
|
||||
val ckQueries = paymentsDb.database.cloudKitPaymentsQueries
|
||||
db.transaction {
|
||||
|
||||
val paymentIds = mutableListOf<UUID>()
|
||||
ckQueries.listNonZeroSizes().executeAsList().forEach { row ->
|
||||
paymentIds.add(row.id)
|
||||
}
|
||||
|
||||
for (paymentId in paymentIds) {
|
||||
ckQueries.addToQueue(
|
||||
id = paymentId,
|
||||
date_added = currentTimestampMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MissingItem(
|
||||
val paymentId: UUID,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
suspend fun enqueueMissingItems() {
|
||||
withContext(Dispatchers.Default) {
|
||||
|
||||
val ckQueries = paymentsDb.database.cloudKitPaymentsQueries
|
||||
db.transaction {
|
||||
|
||||
// Step 1 of 3:
|
||||
// Fetch list of payment ID's that are already represented in the cloud.
|
||||
|
||||
val existing = mutableSetOf<UUID>()
|
||||
ckQueries.scanMetadata().executeAsList().forEach { uuid ->
|
||||
existing.add(uuid)
|
||||
}
|
||||
|
||||
// Step 2 of 3:
|
||||
// Scan local payment ID's, looking to see if any are missing from the cloud.
|
||||
|
||||
val missing = mutableListOf<MissingItem>()
|
||||
run {
|
||||
val inQueries = paymentsDb.database.paymentsIncomingQueries
|
||||
inQueries.listSuccessfulIds().executeAsList().forEach { row ->
|
||||
if (!existing.contains(row.id)) {
|
||||
missing.add(MissingItem(row.id, row.received_at))
|
||||
}
|
||||
}
|
||||
}
|
||||
run {
|
||||
val outQueries = paymentsDb.database.paymentsOutgoingQueries
|
||||
outQueries.listSuccessfulIds().executeAsList().forEach { row ->
|
||||
if (!existing.contains(row.id)) {
|
||||
missing.add(MissingItem(row.id, row.completed_at))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3 of 3:
|
||||
// Add any missing items to the queue.
|
||||
//
|
||||
// But in what order do we want to upload them to the cloud ?
|
||||
//
|
||||
// We will choose to upload the OLDEST item first.
|
||||
// This matches how they normally would have been uploaded.
|
||||
// Also, when a user restores their wallet (e.g. on a new phone),
|
||||
// we always want to download the newest payments first.
|
||||
// And this assumes the newest items in the cloud are the newest payments.
|
||||
//
|
||||
// Since items are uploaded in FIFO order,
|
||||
// we just need to make the oldest item have the
|
||||
// smallest `date_added` value.
|
||||
|
||||
missing.sortByDescending { it.timestamp }
|
||||
|
||||
// The list is now sorted in descending order.
|
||||
// Which means the newest item is at index 0,
|
||||
// and the oldest item is at index <last>.
|
||||
|
||||
val now = currentTimestampMillis()
|
||||
missing.forEachIndexed { idx, item ->
|
||||
ckQueries.addToQueue(
|
||||
id = item.paymentId,
|
||||
date_added = now - idx
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearDatabaseTables() {
|
||||
withContext(Dispatchers.Default) {
|
||||
db.transaction {
|
||||
queries.deleteAllFromMetadata()
|
||||
queries.deleteAllFromQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package fr.acinq.phoenix.db
|
||||
|
||||
import app.cash.sqldelight.db.SqlDriver
|
||||
import app.cash.sqldelight.driver.native.NativeSqliteDriver
|
||||
import app.cash.sqldelight.driver.native.wrapConnection
|
||||
import co.touchlab.sqliter.DatabaseConfiguration
|
||||
import fr.acinq.phoenix.db.migrations.v10.AfterVersion10
|
||||
import fr.acinq.phoenix.db.migrations.v11.AfterVersion11
|
||||
import fr.acinq.phoenix.db.sqldelight.AppDatabase
|
||||
import fr.acinq.phoenix.db.sqldelight.ChannelsDatabase
|
||||
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
|
||||
import fr.acinq.phoenix.utils.PlatformContext
|
||||
import fr.acinq.phoenix.utils.getDatabaseFilesDirectoryPath
|
||||
|
||||
actual fun createChannelsDbDriver(
|
||||
ctx: PlatformContext,
|
||||
fileName: String
|
||||
): SqlDriver {
|
||||
val schema = ChannelsDatabase.Schema
|
||||
|
||||
// The foreign_keys constraint needs to be set via the DatabaseConfiguration:
|
||||
// https://github.com/cashapp/sqldelight/issues/1356
|
||||
|
||||
val dbDir = getDatabaseFilesDirectoryPath(ctx)
|
||||
val configuration = DatabaseConfiguration(
|
||||
name = fileName,
|
||||
version = schema.version.toInt(),
|
||||
extendedConfig = DatabaseConfiguration.Extended(
|
||||
basePath = dbDir,
|
||||
foreignKeyConstraints = true
|
||||
),
|
||||
create = { connection ->
|
||||
wrapConnection(connection) { schema.create(it) }
|
||||
},
|
||||
upgrade = { connection, oldVersion, newVersion ->
|
||||
wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong()) }
|
||||
}
|
||||
)
|
||||
return NativeSqliteDriver(configuration)
|
||||
}
|
||||
|
||||
actual fun createPaymentsDbDriver(
|
||||
ctx: PlatformContext,
|
||||
fileName: String,
|
||||
onError: (String) -> Unit
|
||||
): SqlDriver {
|
||||
val schema = PaymentsDatabase.Schema
|
||||
|
||||
val dbDir = getDatabaseFilesDirectoryPath(ctx)
|
||||
val configuration = DatabaseConfiguration(
|
||||
name = fileName,
|
||||
version = schema.version.toInt(),
|
||||
extendedConfig = DatabaseConfiguration.Extended(
|
||||
basePath = dbDir,
|
||||
foreignKeyConstraints = true
|
||||
),
|
||||
create = { connection ->
|
||||
wrapConnection(connection) { schema.create(it) }
|
||||
},
|
||||
upgrade = { connection, oldVersion, newVersion ->
|
||||
wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong(), AfterVersion10(onError), AfterVersion11(onError)) }
|
||||
}
|
||||
)
|
||||
return NativeSqliteDriver(configuration)
|
||||
}
|
||||
|
||||
actual fun createAppDbDriver(
|
||||
ctx: PlatformContext
|
||||
): SqlDriver {
|
||||
val schema = AppDatabase.Schema
|
||||
val name = "app.sqlite"
|
||||
|
||||
val dbDir = getDatabaseFilesDirectoryPath(ctx)
|
||||
val configuration = DatabaseConfiguration(
|
||||
name = name,
|
||||
version = schema.version.toInt(),
|
||||
extendedConfig = DatabaseConfiguration.Extended(
|
||||
basePath = dbDir,
|
||||
foreignKeyConstraints = true
|
||||
),
|
||||
create = { connection ->
|
||||
wrapConnection(connection) { schema.create(it) }
|
||||
},
|
||||
upgrade = { connection, oldVersion, newVersion ->
|
||||
wrapConnection(connection) { schema.migrate(it, oldVersion.toLong(), newVersion.toLong()) }
|
||||
}
|
||||
)
|
||||
return NativeSqliteDriver(configuration)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package fr.acinq.phoenix.db
|
||||
|
||||
import fr.acinq.lightning.utils.UUID
|
||||
import fr.acinq.lightning.utils.currentTimestampMillis
|
||||
import fr.acinq.phoenix.db.payments.CloudKitInterface
|
||||
import fr.acinq.phoenix.db.sqldelight.PaymentsDatabase
|
||||
|
||||
actual fun didSaveWalletPayment(id: UUID, database: PaymentsDatabase) {
|
||||
database.cloudKitPaymentsQueries.addToQueue(id = id, date_added = currentTimestampMillis())
|
||||
}
|
||||
|
||||
actual fun didDeleteWalletPayment(id: UUID, database: PaymentsDatabase) {
|
||||
database.cloudKitPaymentsQueries.addToQueue(id = id, date_added = currentTimestampMillis())
|
||||
}
|
||||
|
||||
actual fun didUpdateWalletPaymentMetadata(id: UUID, database: PaymentsDatabase) {
|
||||
database.cloudKitPaymentsQueries.addToQueue(id = id, date_added = currentTimestampMillis())
|
||||
}
|
||||
|
||||
actual fun didSaveContact(contactId: UUID, database: PaymentsDatabase) {
|
||||
database.cloudKitContactsQueries.addToQueue(
|
||||
id = contactId.toString(),
|
||||
date_added = currentTimestampMillis()
|
||||
)
|
||||
}
|
||||
|
||||
actual fun didDeleteContact(contactId: UUID, database: PaymentsDatabase) {
|
||||
database.cloudKitContactsQueries.addToQueue(
|
||||
id = contactId.toString(),
|
||||
date_added = currentTimestampMillis()
|
||||
)
|
||||
}
|
||||
|
||||
actual fun makeCloudKitDb(appDb: SqliteAppDb, paymentsDb: SqlitePaymentsDb): CloudKitInterface? {
|
||||
return CloudKitDb(appDb, paymentsDb)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
package fr.acinq.phoenix.ios
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.machankura.compose.AppVersion
|
||||
import com.machankura.compose.ui.composable.widgets.wallet.WalletAvatars
|
||||
import fr.acinq.lightning.LiquidityEvents
|
||||
import fr.acinq.lightning.PaymentEvents
|
||||
import fr.acinq.lightning.utils.Connection
|
||||
import fr.acinq.lightning.utils.currentTimestampMillis
|
||||
import fr.acinq.phoenix.BusinessMonitorJobs
|
||||
import fr.acinq.phoenix.BusinessRunning
|
||||
import fr.acinq.phoenix.PhoenixBusiness
|
||||
import fr.acinq.phoenix.PhoenixGlobal
|
||||
import fr.acinq.phoenix.data.StartBusinessResult
|
||||
import fr.acinq.phoenix.data.StartupParams
|
||||
import fr.acinq.phoenix.data.WalletId
|
||||
import fr.acinq.phoenix.data.inFlightPaymentsCount
|
||||
import fr.acinq.phoenix.managers.AppConnectionsDaemon
|
||||
import fr.acinq.phoenix.managers.NodeParamsManager
|
||||
import fr.acinq.phoenix.managers.PeerManager
|
||||
import fr.acinq.phoenix.managers.global.CurrencyManager
|
||||
import fr.acinq.phoenix.utils.DateUtils
|
||||
import fr.acinq.phoenix.utils.MnemonicLanguage
|
||||
import fr.acinq.phoenix.utils.PlatformContext
|
||||
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
|
||||
import fr.acinq.phoenix.utils.preferences.InternalPrefs
|
||||
import fr.acinq.phoenix.utils.preferences.UserPrefs
|
||||
import fr.acinq.phoenix.utils.preferences.UserWalletMetadata
|
||||
import fr.acinq.phoenix.utils.preferences.getByWalletIdOrDefault
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
object BusinessManager {
|
||||
private val log = Logger.withTag("BusinessManager")
|
||||
private val supervisor = SupervisorJob()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + supervisor)
|
||||
|
||||
private val startupMutex = Mutex()
|
||||
|
||||
|
||||
val phoenixGlobal: PhoenixGlobal = PhoenixGlobal(
|
||||
ctx = PlatformContext()
|
||||
)
|
||||
|
||||
|
||||
|
||||
/** A map of (walletId -> active businesses) */
|
||||
private val _businessFlow = MutableStateFlow<Map<WalletId, BusinessRunning>>(emptyMap())
|
||||
val businessFlow = _businessFlow.asStateFlow()
|
||||
|
||||
/** Map of jobs monitoring events/payments once business starts */
|
||||
private val eventsMonitoringJobs = mutableMapOf<WalletId, BusinessMonitorJobs>() //List<Job>>()
|
||||
|
||||
suspend fun startNewBusiness(words: List<String>, isHeadless: Boolean): StartBusinessResult = startupMutex.withLock {
|
||||
val business = PhoenixBusiness(phoenixGlobal)
|
||||
|
||||
val walletInfo = try {
|
||||
val seed = business.walletManager.mnemonicsToSeed(words, wordList = MnemonicLanguage.English.wordlist())
|
||||
business.walletManager.loadWallet(seed)
|
||||
} catch (e: Exception) {
|
||||
return StartBusinessResult.Failure.LoadWalletError
|
||||
}
|
||||
|
||||
val walletId = WalletId(walletInfo.nodeIdHash)
|
||||
val nodeId = walletInfo.nodeId.toHex()
|
||||
|
||||
val dataStoreManager = business.dataStoreManager
|
||||
|
||||
val globalPrefs: GlobalPrefs = dataStoreManager.loadGlobalPrefsForWallet()
|
||||
|
||||
val walletMetadata = globalPrefs.getAvailableWalletsMeta.first()[walletId] ?: run {
|
||||
val metadata = UserWalletMetadata(
|
||||
walletId = walletId,
|
||||
name = null,
|
||||
avatar = WalletAvatars.list.random(),
|
||||
createdAt = currentTimestampMillis(),
|
||||
isHidden = false
|
||||
)
|
||||
globalPrefs.saveAvailableWalletMeta(metadata)
|
||||
metadata
|
||||
}
|
||||
val userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId)
|
||||
val internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId)
|
||||
|
||||
val businessInFlow = businessFlow.value[walletId]?.business
|
||||
if (businessInFlow != null) {
|
||||
log.i { "business already exists in flow, ignoring..." }
|
||||
return StartBusinessResult.Success(walletInfo, businessInFlow)
|
||||
}
|
||||
|
||||
|
||||
return try {
|
||||
log.i("preparing new business with node_id=$nodeId wallet_id=$walletId...")
|
||||
|
||||
// check last used version to display a patch note
|
||||
val lastVersionUsed = globalPrefs.getLastUsedAppCode.first()
|
||||
if (lastVersionUsed == null) {
|
||||
// lastUsedAppCode was added in version 99, and is set up during the wallet creation. So if it's null, this Phoenix was installed prior v99 and we can show a patch note
|
||||
globalPrefs.saveShowReleaseNoteSinceCode("98")
|
||||
}
|
||||
else if (lastVersionUsed < AppVersion.versionCode) {
|
||||
globalPrefs.saveShowReleaseNoteSinceCode(lastVersionUsed)
|
||||
}
|
||||
|
||||
// update app configuration with user preferences
|
||||
business.appConfigurationManager.updateElectrumConfig(userPrefs.getElectrumServer.first())
|
||||
val preferredCurrencies = userPrefs.getFiatCurrencies.first()
|
||||
business.appConfigurationManager.updatePreferredFiatCurrencies(preferredCurrencies)
|
||||
business.phoenixGlobal.currencyManager.startMonitoringCurrencies(walletId = walletId.nodeIdHash, currencies = preferredCurrencies)
|
||||
|
||||
// setup jobs monitoring the business events
|
||||
eventsMonitoringJobs[walletId] = BusinessMonitorJobs(
|
||||
monitorHeadlessPaymentsJob = if (isHeadless) {
|
||||
scope.launch { monitorPaymentsWhenHeadless(walletId, walletMetadata, business.nodeParamsManager, phoenixGlobal.currencyManager, userPrefs) }
|
||||
} else null,
|
||||
monitorNodeEventsJob = scope.launch { monitorNodeEvents(walletId, business.peerManager, business.nodeParamsManager, globalPrefs, internalPrefs) },
|
||||
monitorFcmTokenJob = scope.launch { monitorFcmToken(globalPrefs, business) },
|
||||
monitorInFlightPaymentsJob = scope.launch { monitorInFlightPayments(business.peerManager, internalPrefs) },
|
||||
)
|
||||
|
||||
// startup params depend user's settings: Tor and liquidity policy
|
||||
val startupParams = StartupParams(isTorEnabled = userPrefs.getIsTorEnabled.first(), liquidityPolicy = userPrefs.getLiquidityPolicy.first())
|
||||
delay(1_000)
|
||||
|
||||
// actually start the business
|
||||
log.i("starting new business with node_id=$nodeId...")
|
||||
_businessFlow.value += walletId to BusinessRunning(business = business, isHeadless = isHeadless)
|
||||
business.start(startupParams)
|
||||
|
||||
// the node has been started, so we can now increment the last-used build code
|
||||
globalPrefs.saveLastUsedAppCode(AppVersion.versionCode)
|
||||
|
||||
// start watching the swap-in wallet
|
||||
scope.launch {
|
||||
business.peerManager.getPeer().startWatchSwapInWallet()
|
||||
}
|
||||
|
||||
log.i("business initialisation has successfully completed")
|
||||
StartBusinessResult.Success(walletInfo, business)
|
||||
} catch (e: Exception) {
|
||||
log.e("there was an error when initialising new business: ", e)
|
||||
stopBusiness(walletId)
|
||||
StartBusinessResult.Failure.Generic(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the matching business in the map of active businesses with a non-headless flag. Should be called when the UI starts a given wallet.
|
||||
* If called improperly, will not have severe effects ; the app will just show incoming payment notifications.
|
||||
*/
|
||||
fun updateBusinessActiveInUI(walletId: WalletId) {
|
||||
val businessMap = _businessFlow.value.toMutableMap()
|
||||
businessMap[walletId]?.let {
|
||||
businessMap[walletId] = it.copy(isHeadless = false)
|
||||
}
|
||||
eventsMonitoringJobs[walletId]?.monitorHeadlessPaymentsJob?.cancel()
|
||||
_businessFlow.value = businessMap
|
||||
}
|
||||
|
||||
fun stopAllHeadlessBusinesses() {
|
||||
val headlessBusinesses = businessFlow.value.filter { it.value.isHeadless }
|
||||
log.i("stopping all headless businesses (${headlessBusinesses.size})...")
|
||||
headlessBusinesses.forEach { doStopBusiness(it.key, it.value) }
|
||||
_businessFlow.value = businessFlow.value.minus(headlessBusinesses.keys)
|
||||
}
|
||||
|
||||
fun stopAllBusinesses() {
|
||||
log.i("stopping all businesses...")
|
||||
businessFlow.value.forEach { doStopBusiness(it.key, it.value) }
|
||||
_businessFlow.value = emptyMap()
|
||||
}
|
||||
|
||||
fun stopBusiness(walletId: WalletId) {
|
||||
val businessMap = _businessFlow.value.toMutableMap()
|
||||
businessMap[walletId]?.let { doStopBusiness(walletId, it)}
|
||||
businessMap.remove(walletId)
|
||||
_businessFlow.value = businessMap
|
||||
}
|
||||
|
||||
private fun doStopBusiness(walletId: WalletId, running: BusinessRunning) {
|
||||
running.business.appConnectionsDaemon?.incrementDisconnectCount(AppConnectionsDaemon.ControlTarget.All)
|
||||
running.business.stop()
|
||||
eventsMonitoringJobs.remove(walletId)?.let {
|
||||
it.monitorHeadlessPaymentsJob?.cancel()
|
||||
it.monitorFcmTokenJob.cancel()
|
||||
it.monitorNodeEventsJob.cancel()
|
||||
it.monitorInFlightPaymentsJob.cancel()
|
||||
}
|
||||
phoenixGlobal.currencyManager.stopMonitoringForWallet(walletId.nodeIdHash)
|
||||
}
|
||||
|
||||
fun refreshFcmToken() {
|
||||
// TODO: FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
|
||||
// if (!task.isSuccessful) {
|
||||
// fr.acinq.phoenix.android.BusinessManager.log.warn("fetching FCM registration token failed: ${task.exception?.localizedMessage}")
|
||||
// return@OnCompleteListener
|
||||
// }
|
||||
// task.result?.let { fr.acinq.phoenix.android.BusinessManager.scope.launch { application.globalPrefs.saveFcmToken(it) } }
|
||||
// })
|
||||
}
|
||||
|
||||
private suspend fun monitorFcmToken(globalPrefs: GlobalPrefs,business: PhoenixBusiness) {
|
||||
val token = globalPrefs.getFcmToken.filterNotNull().first()
|
||||
business.connectionsManager.connections.first { it.peer == Connection.ESTABLISHED }
|
||||
delay(5000)
|
||||
log.i("registering fcm token=$token")
|
||||
business.registerFcmToken(token)
|
||||
}
|
||||
|
||||
private suspend fun monitorNodeEvents(walletId: WalletId, peerManager: PeerManager, nodeParamsManager: NodeParamsManager, globalPrefs: GlobalPrefs, internalPrefs: InternalPrefs) {
|
||||
val monitoringStartedAt = currentTimestampMillis()
|
||||
combine(
|
||||
peerManager.swapInNextTimeout,
|
||||
nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents
|
||||
) { nextTimeout, nodeEvent ->
|
||||
nextTimeout to nodeEvent
|
||||
}.collect { (_, event) ->
|
||||
// TODO: click on notif must deeplink to the notification screen
|
||||
when (event) {
|
||||
is LiquidityEvents.Rejected -> {
|
||||
log.d("processing liquidity_event=$event")
|
||||
if (event.source == LiquidityEvents.Source.OnChainWallet) {
|
||||
// Check the last time a rejected on-chain swap notification has been shown. If recent, we do not want to trigger a notification every time.
|
||||
val lastRejectedSwap = internalPrefs.getLastRejectedOnchainSwap.first().takeIf {
|
||||
// However, if the app started < 2 min ago, we always want to display a notification. So we'll ignore this check ^
|
||||
currentTimestampMillis() - monitoringStartedAt >= 2 * DateUtils.MINUTE_IN_MILLIS
|
||||
}
|
||||
if (lastRejectedSwap != null
|
||||
&& lastRejectedSwap.first == event.amount
|
||||
&& currentTimestampMillis() - lastRejectedSwap.second <= 2 * DateUtils.HOUR_IN_MILLIS
|
||||
) {
|
||||
log.d("ignore this liquidity event as a similar notification was recently displayed")
|
||||
return@collect
|
||||
} else {
|
||||
internalPrefs.saveLastRejectedOnchainSwap(event)
|
||||
}
|
||||
}
|
||||
globalPrefs.getAvailableWalletsMeta.first().getByWalletIdOrDefault(walletId)
|
||||
when (val reason = event.reason) {
|
||||
is LiquidityEvents.Rejected.Reason.PolicySetToDisabled -> {
|
||||
// TODO: SystemNotificationHelper.notifyPaymentRejectedPolicyDisabled(appContext, walletId, walletMetadata, event.source, event.amount, nextTimeout?.second)
|
||||
}
|
||||
is LiquidityEvents.Rejected.Reason.TooExpensive.OverAbsoluteFee -> {
|
||||
// TODO: SSystemNotificationHelper.notifyPaymentRejectedOverAbsolute(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxAbsoluteFee, nextTimeout?.second)
|
||||
}
|
||||
is LiquidityEvents.Rejected.Reason.TooExpensive.OverRelativeFee -> {
|
||||
// TODO: SSystemNotificationHelper.notifyPaymentRejectedOverRelative(appContext, walletId, walletMetadata, event.source, event.amount, event.fee, reason.maxRelativeFeeBasisPoints, nextTimeout?.second)
|
||||
}
|
||||
is LiquidityEvents.Rejected.Reason.MissingOffChainAmountTooLow -> {
|
||||
// TODO: SSystemNotificationHelper.notifyPaymentRejectedAmountTooLow(appContext, walletId, walletMetadata, event.source, event.amount)
|
||||
}
|
||||
// Temporary errors
|
||||
is LiquidityEvents.Rejected.Reason.ChannelFundingInProgress,
|
||||
is LiquidityEvents.Rejected.Reason.NoMatchingFundingRate,
|
||||
is LiquidityEvents.Rejected.Reason.TooManyParts -> {
|
||||
// TODO: SSystemNotificationHelper.notifyPaymentRejectedFundingError(appContext, walletId, walletMetadata, event.source, event.amount)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun monitorPaymentsWhenHeadless(walletId: WalletId, walletMetadata: UserWalletMetadata, nodeParamsManager: NodeParamsManager, currencyManager: CurrencyManager, userPrefs: UserPrefs) {
|
||||
nodeParamsManager.nodeParams.filterNotNull().first().nodeEvents.collect { event ->
|
||||
when (event) {
|
||||
is PaymentEvents.PaymentReceived -> {
|
||||
// TODO: SystemNotificationHelper.notifyPaymentsReceived(
|
||||
// context = appContext,
|
||||
// userPrefs = userPrefs,
|
||||
// walletId = walletId,
|
||||
// userWalletMetadata = walletMetadata,
|
||||
// paymentId = event.payment.id,
|
||||
// paymentAmount = event.payment.amountReceived,
|
||||
// rates = currencyManager.ratesFlow.value,
|
||||
// )
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun monitorInFlightPayments(peerManager: PeerManager, internalPrefs: InternalPrefs) {
|
||||
peerManager.channelsFlow.filterNotNull().collect {
|
||||
val inFlightPaymentsCount = it.inFlightPaymentsCount()
|
||||
internalPrefs.saveInFlightPaymentsCount(inFlightPaymentsCount)
|
||||
// TODO: InflightPaymentsWatcher
|
||||
// if (inFlightPaymentsCount == 0) {
|
||||
// InflightPaymentsWatcher.cancel(appContext)
|
||||
// } else {
|
||||
// InflightPaymentsWatcher.scheduleOnce(appContext, delay = 2.hours)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
supervisor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package fr.acinq.phoenix.managers
|
||||
|
||||
import fr.acinq.phoenix.utils.PlatformContext
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import okio.Path
|
||||
import okio.Path.Companion.toPath
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSURL
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
actual fun computePreferencePath(
|
||||
platformContext: PlatformContext,
|
||||
dataStoreFileName: String
|
||||
): Path {
|
||||
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
|
||||
directory = NSDocumentDirectory,
|
||||
inDomain = NSUserDomainMask,
|
||||
appropriateForURL = null,
|
||||
create = false,
|
||||
error = null,
|
||||
)
|
||||
val path = requireNotNull(documentDirectory).path + "${Path.DIRECTORY_SEPARATOR}$dataStoreFileName"
|
||||
|
||||
return path.toPath()
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package fr.acinq.phoenix.managers.global
|
||||
|
||||
import fr.acinq.lightning.logging.LoggerFactory
|
||||
import fr.acinq.lightning.logging.debug
|
||||
import fr.acinq.phoenix.utils.PlatformContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import platform.Network.nw_path_get_status
|
||||
import platform.Network.nw_path_monitor_cancel
|
||||
import platform.Network.nw_path_monitor_create
|
||||
import platform.Network.nw_path_monitor_set_queue
|
||||
import platform.Network.nw_path_monitor_set_update_handler
|
||||
import platform.Network.nw_path_monitor_start
|
||||
import platform.Network.nw_path_monitor_t
|
||||
import platform.Network.nw_path_status_invalid
|
||||
import platform.Network.nw_path_status_satisfiable
|
||||
import platform.Network.nw_path_status_satisfied
|
||||
import platform.Network.nw_path_status_unsatisfied
|
||||
import platform.darwin.dispatch_get_main_queue
|
||||
|
||||
actual class NetworkMonitor actual constructor(
|
||||
loggerFactory: LoggerFactory,
|
||||
ctx: PlatformContext
|
||||
) : CoroutineScope by MainScope() {
|
||||
|
||||
private val logger = loggerFactory.newLogger(this::class)
|
||||
|
||||
private val _networkState = MutableStateFlow(NetworkState.NotAvailable)
|
||||
actual val networkState: StateFlow<NetworkState> = _networkState
|
||||
|
||||
private var enabled = true
|
||||
private var monitor: nw_path_monitor_t = null
|
||||
|
||||
actual fun enable() {
|
||||
enabled = true
|
||||
start()
|
||||
}
|
||||
|
||||
actual fun disable() {
|
||||
enabled = false
|
||||
stop()
|
||||
_networkState.value = NetworkState.Available
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
actual fun start() {
|
||||
if (!enabled || monitor != null) {
|
||||
return
|
||||
}
|
||||
|
||||
monitor = nw_path_monitor_create()
|
||||
nw_path_monitor_set_update_handler(monitor) { path ->
|
||||
val status = when (nw_path_get_status(path)) {
|
||||
nw_path_status_satisfied -> {
|
||||
logger.debug { "status = nw_path_status_satisfied" }
|
||||
NetworkState.Available
|
||||
}
|
||||
nw_path_status_satisfiable -> {
|
||||
logger.debug { "status = nw_path_status_satisfiable" }
|
||||
NetworkState.Available
|
||||
}
|
||||
nw_path_status_unsatisfied -> {
|
||||
logger.debug { "status = nw_path_status_unsatisfied" }
|
||||
NetworkState.NotAvailable
|
||||
}
|
||||
nw_path_status_invalid -> {
|
||||
logger.debug { "status = nw_path_status_invalid" }
|
||||
NetworkState.NotAvailable
|
||||
}
|
||||
else -> {
|
||||
logger.debug { "status = nw_path_status_unknown" }
|
||||
NetworkState.NotAvailable
|
||||
}
|
||||
}
|
||||
|
||||
launch { _networkState.value = status }
|
||||
}
|
||||
|
||||
nw_path_monitor_set_queue(monitor, dispatch_get_main_queue())
|
||||
nw_path_monitor_start(monitor)
|
||||
}
|
||||
|
||||
actual fun stop() {
|
||||
if (monitor != null) {
|
||||
// NB: once cancelled, a monitor instance cannot be started again
|
||||
nw_path_monitor_cancel(monitor)
|
||||
monitor = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package fr.acinq.phoenix.security
|
||||
|
||||
import kotlinx.cinterop.ByteVar
|
||||
import kotlinx.cinterop.CValuesRef
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.MemScope
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.pin
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.refTo
|
||||
import kotlinx.cinterop.value
|
||||
import platform.CoreCrypto.CCAlgorithm
|
||||
import platform.CoreCrypto.CCCryptorCreateWithMode
|
||||
import platform.CoreCrypto.CCCryptorFinal
|
||||
import platform.CoreCrypto.CCCryptorGetOutputLength
|
||||
import platform.CoreCrypto.CCCryptorRefVar
|
||||
import platform.CoreCrypto.CCCryptorRelease
|
||||
import platform.CoreCrypto.CCCryptorStatus
|
||||
import platform.CoreCrypto.CCCryptorUpdate
|
||||
import platform.CoreCrypto.CCMode
|
||||
import platform.CoreCrypto.CCOperation
|
||||
import platform.CoreCrypto.CCPadding
|
||||
import platform.CoreCrypto.ccPKCS7Padding
|
||||
import platform.CoreCrypto.kCCAlgorithmAES
|
||||
import platform.CoreCrypto.kCCAlignmentError
|
||||
import platform.CoreCrypto.kCCBufferTooSmall
|
||||
import platform.CoreCrypto.kCCDecodeError
|
||||
import platform.CoreCrypto.kCCDecrypt
|
||||
import platform.CoreCrypto.kCCEncrypt
|
||||
import platform.CoreCrypto.kCCMemoryFailure
|
||||
import platform.CoreCrypto.kCCModeCBC
|
||||
import platform.CoreCrypto.kCCParamError
|
||||
import platform.CoreCrypto.kCCSuccess
|
||||
import platform.CoreCrypto.kCCUnimplemented
|
||||
import platform.posix.size_tVar
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private val almostEmptyArrayPinned = ByteArray(1).pin()
|
||||
|
||||
@ExperimentalForeignApi
|
||||
fun ByteArray.safeRefTo(index: Int): CValuesRef<ByteVar> {
|
||||
if (index == size) return almostEmptyArrayPinned.addressOf(0)
|
||||
return refTo(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Might just load up cryptography-kotlin as a dependency as it would allow us to have this logic in commonMain
|
||||
* https://github.com/whyoleg/cryptography-kotlin.git
|
||||
*/
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
class CCCipher(
|
||||
private val algorithm: CCAlgorithm,
|
||||
private val mode: CCMode,
|
||||
private val padding: CCPadding,
|
||||
private val key: ByteArray,
|
||||
) {
|
||||
companion object {
|
||||
fun phoenixCipherForKey(key: ByteArray): CCCipher {
|
||||
return CCCipher(
|
||||
algorithm = kCCAlgorithmAES,
|
||||
mode = kCCModeCBC,
|
||||
padding = ccPKCS7Padding,
|
||||
key = key,
|
||||
)
|
||||
}
|
||||
}
|
||||
fun encrypt(iv: ByteArray?, plaintext: ByteArray): ByteArray = memScoped {
|
||||
useCryptor { cryptorRef ->
|
||||
cryptorRef.create(kCCEncrypt, iv?.refTo(0))
|
||||
val ciphertextOutput = ByteArray(cryptorRef.outputLength(plaintext.size))
|
||||
|
||||
val dataOutMoved = alloc<size_tVar>()
|
||||
val moved = cryptorRef.update(
|
||||
dataIn = plaintext.safeRefTo(0),
|
||||
dataInLength = plaintext.size,
|
||||
dataOut = ciphertextOutput.safeRefTo(0),
|
||||
dataOutAvailable = ciphertextOutput.size,
|
||||
dataOutMoved = dataOutMoved,
|
||||
)
|
||||
|
||||
if (ciphertextOutput.size != moved) cryptorRef.final(
|
||||
dataOut = ciphertextOutput.refTo(moved),
|
||||
dataOutAvailable = ciphertextOutput.size - moved,
|
||||
dataOutMoved = dataOutMoved,
|
||||
)
|
||||
ciphertextOutput
|
||||
}
|
||||
}
|
||||
|
||||
fun decrypt(iv: ByteArray?, ciphertext: ByteArray, ciphertextStartIndex: Int): ByteArray = memScoped {
|
||||
useCryptor { cryptorRef ->
|
||||
cryptorRef.create(kCCDecrypt, iv?.refTo(0))
|
||||
|
||||
val plaintextOutput = ByteArray(cryptorRef.outputLength(ciphertext.size - ciphertextStartIndex))
|
||||
|
||||
val dataOutMoved = alloc<size_tVar>()
|
||||
var moved = cryptorRef.update(
|
||||
dataIn = ciphertext.safeRefTo(ciphertextStartIndex),
|
||||
dataInLength = ciphertext.size - ciphertextStartIndex,
|
||||
dataOut = plaintextOutput.safeRefTo(0),
|
||||
dataOutAvailable = plaintextOutput.size,
|
||||
dataOutMoved = dataOutMoved
|
||||
)
|
||||
|
||||
if (plaintextOutput.size != moved) moved += cryptorRef.final(
|
||||
dataOut = plaintextOutput.refTo(moved),
|
||||
dataOutAvailable = plaintextOutput.size - moved,
|
||||
dataOutMoved = dataOutMoved
|
||||
)
|
||||
|
||||
if (plaintextOutput.size == moved) {
|
||||
plaintextOutput
|
||||
} else {
|
||||
plaintextOutput.copyOf(moved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> MemScope.useCryptor(block: (cryptorRef: CCCryptorRefVar) -> T): T {
|
||||
val cryptorRef = alloc<CCCryptorRefVar>()
|
||||
try {
|
||||
return block(cryptorRef)
|
||||
} finally {
|
||||
CCCryptorRelease(cryptorRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CCCryptorRefVar.create(op: CCOperation, iv: CValuesRef<*>?) {
|
||||
checkResult(
|
||||
CCCryptorCreateWithMode(
|
||||
op = op,
|
||||
cryptorRef = ptr,
|
||||
alg = algorithm,
|
||||
mode = mode,
|
||||
padding = padding,
|
||||
key = key.refTo(0),
|
||||
keyLength = key.size.convert(),
|
||||
iv = iv,
|
||||
|
||||
// unused options
|
||||
options = 0.convert(),
|
||||
tweak = null,
|
||||
tweakLength = 0.convert(),
|
||||
numRounds = 0,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun CCCryptorRefVar.outputLength(inputLength: Int): Int {
|
||||
return CCCryptorGetOutputLength(
|
||||
cryptorRef = value,
|
||||
inputLength = inputLength.convert(),
|
||||
final = true
|
||||
).convert()
|
||||
}
|
||||
|
||||
private fun CCCryptorRefVar.update(
|
||||
dataIn: CValuesRef<*>,
|
||||
dataInLength: Int,
|
||||
dataOut: CValuesRef<*>,
|
||||
dataOutAvailable: Int,
|
||||
dataOutMoved: size_tVar,
|
||||
): Int {
|
||||
checkResult(
|
||||
CCCryptorUpdate(
|
||||
cryptorRef = value,
|
||||
dataIn = dataIn,
|
||||
dataInLength = dataInLength.convert(),
|
||||
dataOut = dataOut,
|
||||
dataOutAvailable = dataOutAvailable.convert(),
|
||||
dataOutMoved = dataOutMoved.ptr
|
||||
)
|
||||
)
|
||||
return dataOutMoved.value.convert()
|
||||
}
|
||||
|
||||
private fun CCCryptorRefVar.final(
|
||||
dataOut: CValuesRef<*>,
|
||||
dataOutAvailable: Int,
|
||||
dataOutMoved: size_tVar,
|
||||
): Int {
|
||||
checkResult(
|
||||
CCCryptorFinal(
|
||||
cryptorRef = value,
|
||||
dataOut = dataOut,
|
||||
dataOutAvailable = dataOutAvailable.convert(),
|
||||
dataOutMoved = dataOutMoved.ptr
|
||||
)
|
||||
)
|
||||
return dataOutMoved.value.convert()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkResult(result: CCCryptorStatus) {
|
||||
error(
|
||||
when (result) {
|
||||
kCCSuccess -> return
|
||||
kCCParamError -> "Illegal parameter value."
|
||||
kCCBufferTooSmall -> "Insufficient buffer provided for specified operation."
|
||||
kCCMemoryFailure -> "Memory allocation failure."
|
||||
kCCAlignmentError -> "Input size was not aligned properly."
|
||||
kCCDecodeError -> "Input data did not decode or decrypt properly."
|
||||
kCCUnimplemented -> "Function not implemented for the current algorithm."
|
||||
else -> "CCCrypt failed with code $result"
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package fr.acinq.phoenix.security
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.machankura.compose.AppVersion
|
||||
import fr.acinq.lightning.Lightning
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.alloc
|
||||
import kotlinx.cinterop.allocArrayOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.memScoped
|
||||
import kotlinx.cinterop.ptr
|
||||
import kotlinx.cinterop.usePinned
|
||||
import kotlinx.cinterop.value
|
||||
import platform.CoreFoundation.CFAutorelease
|
||||
import platform.CoreFoundation.CFDictionaryAddValue
|
||||
import platform.CoreFoundation.CFDictionaryCreateMutable
|
||||
import platform.CoreFoundation.CFDictionaryRef
|
||||
import platform.CoreFoundation.CFStringRef
|
||||
import platform.CoreFoundation.CFTypeRef
|
||||
import platform.CoreFoundation.CFTypeRefVar
|
||||
import platform.CoreFoundation.kCFBooleanFalse
|
||||
import platform.CoreFoundation.kCFBooleanTrue
|
||||
import platform.Foundation.CFBridgingRelease
|
||||
import platform.Foundation.CFBridgingRetain
|
||||
import platform.Foundation.NSData
|
||||
import platform.Foundation.NSKeyedArchiver
|
||||
import platform.Foundation.NSKeyedUnarchiver
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.NSString
|
||||
import platform.Foundation.NSUTF8StringEncoding
|
||||
import platform.Foundation.create
|
||||
import platform.Foundation.dataUsingEncoding
|
||||
import platform.Security.SecItemAdd
|
||||
import platform.Security.SecItemCopyMatching
|
||||
import platform.Security.SecItemUpdate
|
||||
import platform.Security.kSecAttrAccessGroup
|
||||
import platform.Security.kSecAttrAccessible
|
||||
import platform.Security.kSecAttrAccessibleAfterFirstUnlock
|
||||
import platform.Security.kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
import platform.Security.kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
|
||||
import platform.Security.kSecAttrAccessibleWhenUnlocked
|
||||
import platform.Security.kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
||||
import platform.Security.kSecAttrAccount
|
||||
import platform.Security.kSecAttrService
|
||||
import platform.Security.kSecClass
|
||||
import platform.Security.kSecClassGenericPassword
|
||||
import platform.Security.kSecMatchLimit
|
||||
import platform.Security.kSecMatchLimitOne
|
||||
import platform.Security.kSecReturnData
|
||||
import platform.Security.kSecValueData
|
||||
import platform.darwin.OSStatus
|
||||
import platform.darwin.noErr
|
||||
import platform.posix.memcpy
|
||||
|
||||
/***
|
||||
* If the KVault repo was being maintained we would've probably just used that as a dependency
|
||||
*
|
||||
* https://github.com/Liftric/KVault
|
||||
*/
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
object KeyChainHelper {
|
||||
private val accessibility: Accessible = Accessible.AfterFirstUnlock
|
||||
/**
|
||||
* kSecAttrAccessible attributes wrapper.
|
||||
* attribute enables you to control item availability relative to the lock state of the device.
|
||||
* It also lets you specify eligibility for restoration to a new device.
|
||||
* If the attribute ends with the string ThisDeviceOnly, the item can be restored to the same device
|
||||
* that created a backup, but it isn’t migrated when restoring another device’s backup data.
|
||||
*/
|
||||
enum class Accessible(val value: CFStringRef?) {
|
||||
WhenPasscodeSetThisDeviceOnly(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly),
|
||||
WhenUnlockedThisDeviceOnly(kSecAttrAccessibleWhenUnlockedThisDeviceOnly),
|
||||
WhenUnlocked(kSecAttrAccessibleWhenUnlocked),
|
||||
AfterFirstUnlock(kSecAttrAccessibleAfterFirstUnlock),
|
||||
AfterFirstUnlockThisDeviceOnly(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
}
|
||||
|
||||
private val log = Logger.withTag("KeyChainHelper")
|
||||
|
||||
|
||||
/**
|
||||
* Returns the data value of an object in the store.
|
||||
* @param forKey The key to query
|
||||
* @return The stored bytes value
|
||||
*/
|
||||
fun data(forKey: String): ByteArray? {
|
||||
log.i("Getting byteArray for $forKey")
|
||||
return value(forKey)?.toByteArray()
|
||||
}
|
||||
|
||||
private fun value(forKey: String): NSData? = context(forKey) { (account) ->
|
||||
log.i("value for $forKey")
|
||||
val query = query(
|
||||
kSecClass to kSecClassGenericPassword,
|
||||
kSecAttrAccount to account,
|
||||
kSecReturnData to kCFBooleanTrue,
|
||||
kSecMatchLimit to kSecMatchLimitOne,
|
||||
)
|
||||
log.i("query: $query")
|
||||
|
||||
memScoped {
|
||||
val result = alloc<CFTypeRefVar>()
|
||||
SecItemCopyMatching(query, result.ptr)
|
||||
CFBridgingRelease(result.value) as? NSData
|
||||
}
|
||||
}
|
||||
|
||||
fun set(key: String, dataValue: ByteArray): Boolean {
|
||||
log.i("set for $key")
|
||||
return addOrUpdate(key, dataValue.toNSData())
|
||||
}
|
||||
|
||||
private fun addOrUpdate(key: String, value: NSData?): Boolean {
|
||||
log.i("addOrUpdate for $key")
|
||||
return if (existsObject(key)) {
|
||||
update(key, value)
|
||||
} else {
|
||||
add(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if object with the given key exists in the Keychain.
|
||||
* @param forKey The key to query
|
||||
* @return True or false, depending on whether it is in the Keychain or not
|
||||
*/
|
||||
fun existsObject(forKey: String): Boolean = context(forKey) { (account) ->
|
||||
log.i("existsObject for $forKey")
|
||||
val query = query(
|
||||
kSecClass to kSecClassGenericPassword,
|
||||
kSecAttrAccount to account,
|
||||
kSecReturnData to kCFBooleanFalse,
|
||||
)
|
||||
log.i("query: $query")
|
||||
|
||||
SecItemCopyMatching(query, null)
|
||||
.validate()
|
||||
}
|
||||
|
||||
|
||||
private fun add(key: String, value: NSData?): Boolean = context(key, value) { (account, data) ->
|
||||
log.i("add value for $key")
|
||||
val query = query(
|
||||
kSecClass to kSecClassGenericPassword,
|
||||
kSecAttrAccount to account,
|
||||
kSecValueData to data,
|
||||
kSecAttrAccessible to accessibility.value
|
||||
)
|
||||
log.i("query: $query")
|
||||
SecItemAdd(query, null)
|
||||
.validate()
|
||||
|
||||
}
|
||||
|
||||
private fun update(key: String, value: Any?): Boolean = context(key, value) { (account, data) ->
|
||||
log.i("update for $key")
|
||||
val query = query(
|
||||
kSecClass to kSecClassGenericPassword,
|
||||
kSecAttrAccount to account,
|
||||
kSecReturnData to kCFBooleanFalse,
|
||||
)
|
||||
|
||||
val updateQuery = query(
|
||||
kSecValueData to data
|
||||
)
|
||||
|
||||
SecItemUpdate(query, updateQuery)
|
||||
.validate()
|
||||
}
|
||||
|
||||
private class Context(val refs: Map<CFStringRef?, CFTypeRef?>) {
|
||||
fun query(vararg pairs: Pair<CFStringRef?, CFTypeRef?>): CFDictionaryRef? {
|
||||
val map = mapOf(*pairs).plus(refs.filter { it.value != null })
|
||||
return CFDictionaryCreateMutable(
|
||||
null, map.size.convert(), null, null
|
||||
).apply {
|
||||
map.entries.forEach { CFDictionaryAddValue(this, it.key, it.value) }
|
||||
}.apply {
|
||||
CFAutorelease(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> context(vararg values: Any?, block: Context.(List<CFTypeRef?>) -> T): T {
|
||||
val standard = mapOf(
|
||||
kSecAttrService to CFBridgingRetain(AppVersion.serviceName),
|
||||
kSecAttrAccessGroup to CFBridgingRetain(null)
|
||||
)
|
||||
val custom = arrayOf(*values).map { CFBridgingRetain(it) }
|
||||
return block.invoke(Context(standard), custom).apply {
|
||||
standard.values.plus(custom).forEach { CFBridgingRelease(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toNSData(): NSData? =
|
||||
NSString.create(string = this).dataUsingEncoding(NSUTF8StringEncoding)
|
||||
|
||||
private fun NSNumber.toNSData() = NSKeyedArchiver.archivedDataWithRootObject(this)
|
||||
private fun NSData.toNSNumber() = NSKeyedUnarchiver.unarchiveObjectWithData(this) as? NSNumber
|
||||
|
||||
private val NSData.stringValue: String?
|
||||
get() = NSString.create(this, NSUTF8StringEncoding) as String?
|
||||
|
||||
private fun NSData.toByteArray(): ByteArray =
|
||||
ByteArray(length.toInt()).apply {
|
||||
if (isNotEmpty()) {
|
||||
usePinned {
|
||||
memcpy(it.addressOf(0), this@toByteArray.bytes, this@toByteArray.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toNSData(): NSData =
|
||||
memScoped {
|
||||
NSData.create(bytes = allocArrayOf(this@toNSData), length = this@toNSData.size.convert())
|
||||
}
|
||||
|
||||
private fun OSStatus.validate(): Boolean {
|
||||
log.i("validate: ${toUInt()}")
|
||||
return toUInt() == noErr
|
||||
}
|
||||
|
||||
private fun getOrCreateKeyNoAuthRequired(): ByteArray {
|
||||
log.i("getOrCreateKeyNoAuthRequired")
|
||||
data(KeyStoreNames.KEY_NO_AUTH)?.let {
|
||||
log.i("Key (${KeyStoreNames.KEY_NO_AUTH}) found")
|
||||
return it
|
||||
}
|
||||
log.i("Key (${KeyStoreNames.KEY_NO_AUTH}) not found need to create new key")
|
||||
// TODO: Generate secretKey using keychain
|
||||
val secretKey = Lightning.randomKey().value.toByteArray()
|
||||
|
||||
if (set(KeyStoreNames.KEY_NO_AUTH, secretKey)) {
|
||||
log.i("Successfully set key")
|
||||
} else {
|
||||
log.i("failed to save key")
|
||||
}
|
||||
return secretKey
|
||||
}
|
||||
|
||||
private fun getKeyForName(keyName: String): ByteArray = when (keyName) {
|
||||
KeyStoreNames.KEY_NO_AUTH -> getOrCreateKeyNoAuthRequired()
|
||||
KeyStoreNames.KEY_FOR_PINCODE_V1 -> getOrCreateKeyNoAuthRequired()
|
||||
else -> throw IllegalArgumentException("unhandled key=$keyName")
|
||||
}
|
||||
|
||||
internal fun getEncryptionCipher(keyName: String): CCCipher {
|
||||
val key = getKeyForName(keyName)
|
||||
|
||||
return CCCipher.phoenixCipherForKey(key)
|
||||
}
|
||||
|
||||
internal fun getDecryptionCipher(keyName: String): CCCipher {
|
||||
val key = getKeyForName(keyName)
|
||||
|
||||
return CCCipher.phoenixCipherForKey(key)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package fr.acinq.phoenix.security
|
||||
|
||||
import fr.acinq.lightning.Lightning
|
||||
|
||||
actual fun keyStoreDecryption(
|
||||
keyName: String,
|
||||
iv: ByteArray,
|
||||
ciphertext: ByteArray
|
||||
): ByteArray = KeyChainHelper.getDecryptionCipher(
|
||||
keyName = keyName,
|
||||
).decrypt(
|
||||
iv,
|
||||
ciphertext,
|
||||
0
|
||||
)
|
||||
|
||||
actual fun keyStoreEncryption(keyName: String, plainText: ByteArray): Pair<ByteArray, ByteArray> {
|
||||
val iv = Lightning.randomBytes(16)
|
||||
val cipherText = KeyChainHelper.getEncryptionCipher(keyName).encrypt(
|
||||
iv,
|
||||
plainText
|
||||
)
|
||||
|
||||
return Pair(iv, cipherText)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package fr.acinq.phoenix.utils
|
||||
|
||||
import co.touchlab.kermit.Severity
|
||||
import platform.Foundation.NSCachesDirectory
|
||||
import platform.Foundation.NSDocumentDirectory
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSSearchPathForDirectoriesInDomains
|
||||
import platform.Foundation.NSTemporaryDirectory
|
||||
import platform.Foundation.NSUserDomainMask
|
||||
|
||||
actual class PlatformContext(
|
||||
val logger: ((Severity, String, String) -> Unit)? = null
|
||||
)
|
||||
|
||||
actual fun getApplicationFilesDirectoryPath(ctx: PlatformContext): String =
|
||||
NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true)[0] as String
|
||||
|
||||
actual fun getApplicationCacheDirectoryPath(ctx: PlatformContext): String =
|
||||
NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, true)[0] as String
|
||||
|
||||
actual fun getDatabaseFilesDirectoryPath(ctx: PlatformContext): String? {
|
||||
return NSFileManager.defaultManager.containerURLForSecurityApplicationGroupIdentifier(
|
||||
groupIdentifier = "group.co.acinq.phoenix"
|
||||
)?.URLByAppendingPathComponent(
|
||||
pathComponent = "databases",
|
||||
isDirectory = true
|
||||
)?.path
|
||||
}
|
||||
|
||||
actual fun getTemporaryDirectoryPath(ctx: PlatformContext): String =
|
||||
NSTemporaryDirectory()
|
||||
@@ -1,15 +0,0 @@
|
||||
package fr.acinq.phoenix.utils.extensions
|
||||
|
||||
import fr.acinq.phoenix.data.DecryptSeedResult
|
||||
|
||||
actual inline fun gracefulSingleSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try {
|
||||
action.invoke()
|
||||
} catch (e: Throwable) {
|
||||
return DecryptSeedResult.Failure.DecryptionError(e)
|
||||
}
|
||||
|
||||
actual inline fun gracefulMultiSeedDecryption(action: () -> DecryptSeedResult): DecryptSeedResult = try {
|
||||
action.invoke()
|
||||
} catch (e: Exception) {
|
||||
return DecryptSeedResult.Failure.DecryptionError(e)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package fr.acinq.phoenix.utils.logger
|
||||
|
||||
import co.touchlab.kermit.LogWriter
|
||||
import co.touchlab.kermit.NSLogWriter
|
||||
import co.touchlab.kermit.OSLogWriter
|
||||
import fr.acinq.phoenix.utils.PlatformContext
|
||||
|
||||
actual fun phoenixLogWriters(ctx: PlatformContext): List<LogWriter> {
|
||||
return if (ctx.logger != null) {
|
||||
listOf(NSLogWriter())
|
||||
} else {
|
||||
// TODO: OSLogWriter is disabled for now, as the current version of OSLogStore is buggy
|
||||
listOf(OSLogWriter())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user