Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ jobs:
with:
tool: just

- name: Set up repository
run: just setup
Comment thread
Justxd22 marked this conversation as resolved.

- name: Setup Java
uses: actions/setup-java@v5
with:
Expand Down Expand Up @@ -106,6 +109,9 @@ jobs:
with:
tool: just

- name: Set up repository
run: just setup

- name: Build Rust FFI bindings
run: just build-ios

Expand Down Expand Up @@ -140,6 +146,12 @@ jobs:
with:
toolchain: stable

- name: Set up just
uses: extractions/setup-just@v1

- name: Set up repository
run: just setup

- name: Run tests
run: cd rust && cargo test --workspace

Expand All @@ -153,4 +165,10 @@ jobs:
components: clippy
override: true

- name: Set up just
uses: extractions/setup-just@v1

- name: Set up repository
run: just setup

- run: cd rust && cargo clippy -- -D warnings
4 changes: 4 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[submodule "rust/external/bdk"]
path = rust/external/bdk
url = https://github.com/bitcoindevkit/bdk.git
shallow = true
9 changes: 6 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ The `COVE_KEYSTORE_*` variables in `.envrc.example` are only needed for signed A
## Quick Start

1. Clone the repository
2. Build the Rust library and bindings:
2. Run setup for submodules:
- `just setup`
- This initializes `rust/external/bdk` and applies project patches from `rust/patches/bdk/`
3. Build the Rust library and bindings:
- iOS: `just build-ios` (`just bi`) for simulator or `just build-ios-debug-device` (`just bidd`) for device
- Android: `just build-android` (`just ba`)
3. Open in Xcode (`ios/Cove.xcodeproj`) or Android Studio (`android/`)
4. Build and run
4. Open in Xcode (`ios/Cove.xcodeproj`) or Android Studio (`android/`)
5. Build and run

## Release Builds

Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />

<queries>
<package android:name="org.torproject.android" />
</queries>

<application
android:name=".CoveApplication"
android:allowBackup="true"
Expand Down
32 changes: 32 additions & 0 deletions android/app/src/main/java/org/bitcoinppl/cove/AppManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
import org.bitcoinppl.cove.cloudbackup.CloudBackupManager
import org.bitcoinppl.cove.flows.SendFlow.SendFlowManager
import org.bitcoinppl.cove.flows.SendFlow.SendFlowPresenter
import org.bitcoinppl.cove.tor.parseCoreTorMode
import org.bitcoinppl.cove_core.*
import org.bitcoinppl.cove_core.AppAlertState
import org.bitcoinppl.cove_core.device.KeychainException
Expand Down Expand Up @@ -71,6 +72,13 @@ class AppManager private constructor() : FfiReconcile {
var selectedFiatCurrency by mutableStateOf(Database().globalConfig().selectedFiatCurrency())
private set

// temporary node draft state used across Settings->Network->Node navigation
var pendingNodeUrl by mutableStateOf("")
var pendingNodeName by mutableStateOf("")
var pendingNodeTypeName by mutableStateOf("")
var pendingNodeAwaitingTorSetup by mutableStateOf(false)
var pendingNodeTorValidated by mutableStateOf(false)

// prices and fees
var prices: PriceResponse? by mutableStateOf(runCatching { rust.prices() }.getOrNull())
private set
Expand Down Expand Up @@ -99,6 +107,7 @@ class AppManager private constructor() : FfiReconcile {
Log.d(tag, "Initializing AppManager")
rust.listenForUpdates(this)
wallets = runCatching { Database().wallets().all() }.getOrElse { emptyList() }
warmupTorIfConfigured()
}

/**
Expand Down Expand Up @@ -533,6 +542,29 @@ class AppManager private constructor() : FfiReconcile {
.onFailure { Log.e(tag, "Unable to dispatch app action $action", it) }
}

private fun warmupTorIfConfigured() {
val globalConfig = database.globalConfig()
if (!globalConfig.useTor()) {
return
}

val modeName = runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull()
val mode = parseCoreTorMode(modeName)
if (mode != TorMode.BUILT_IN) {
Log.d(tag, "Tor enabled with mode=$mode; no built-in warmup needed")
return
}

mainScope.launch(Dispatchers.IO) {
runCatching { ensureBuiltInTorBootstrap() }
.onSuccess { endpoint ->
Comment thread
Justxd22 marked this conversation as resolved.
Log.d(tag, "Built-in Tor warmup started at $endpoint")
}.onFailure { error ->
Log.e(tag, "Failed to warm up built-in Tor on launch: ${error.message}", error)
}
}
}

companion object {
@Volatile
private var instance: AppManager? = null
Expand Down
178 changes: 178 additions & 0 deletions android/app/src/main/java/org/bitcoinppl/cove/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand All @@ -79,10 +80,15 @@ import org.bitcoinppl.cove.cloudbackup.CloudBackupPresentationHost
import org.bitcoinppl.cove.cloudbackup.ForegroundUiBridge
import org.bitcoinppl.cove.flows.OnboardingFlow.OnboardingContainer
import org.bitcoinppl.cove.flows.TapSignerFlow.TapSignerContainer
import org.bitcoinppl.cove.flows.SettingsFlow.OrbotPackageHelper
import org.bitcoinppl.cove.flows.SettingsFlow.isOnionNodeUrl
import org.bitcoinppl.cove.flows.SettingsFlow.switchToFirstClearnetPresetNode
import org.bitcoinppl.cove.navigation.CoveNavDisplay
import org.bitcoinppl.cove.nfc.NfcScanSheet
import org.bitcoinppl.cove.nfc.TapCardNfcManager
import org.bitcoinppl.cove.sidebar.SidebarContainer
import org.bitcoinppl.cove.tor.parseCoreTorMode
import org.bitcoinppl.cove.tor.testSocksEndpoint
import org.bitcoinppl.cove.ui.theme.CoveTheme
import org.bitcoinppl.cove.views.LockView
import org.bitcoinppl.cove_core.bootstrap
Expand All @@ -106,9 +112,11 @@ import org.bitcoinppl.cove_core.Route
import org.bitcoinppl.cove_core.RouteFactory
import org.bitcoinppl.cove_core.SettingsRoute
import org.bitcoinppl.cove_core.TapSignerRoute
import org.bitcoinppl.cove_core.TorMode
import org.bitcoinppl.cove_core.Wallet
import org.bitcoinppl.cove_core.WalletType
import org.bitcoinppl.cove_core.CloudBackupStatus
import org.bitcoinppl.cove_core.NodeSelector
import org.bitcoinppl.cove_core.types.ColorSchemeSelection

internal enum class StartupMode {
Expand Down Expand Up @@ -378,6 +386,97 @@ class MainActivity : FragmentActivity() {
null
}
}
val context = LocalContext.current
val uiScope = rememberCoroutineScope()
var startupTorUnavailableMode by remember { mutableStateOf<TorMode?>(null) }
var startupTorUnavailableEndpoint by remember { mutableStateOf("") }
var startupTorCheckCompleted by remember { mutableStateOf(false) }

suspend fun fallbackToClearnetAndDisableTor() {
var fallbackNodeName: String? = null
if (isOnionNodeUrl(app.selectedNode.url)) {
val selector = NodeSelector()
try {
val fallbackResult = switchToFirstClearnetPresetNode(selector)
if (fallbackResult.isFailure) {
val reason =
fallbackResult.exceptionOrNull()?.message
?: "unknown error"
app.alertState =
TaggedItem(
AppAlertState.General(
title = "Fallback failed",
message = "Could not switch to a clearnet node: $reason",
),
)
return
}
fallbackNodeName = fallbackResult.getOrThrow().name
} finally {
selector.close()
}
}

app.pendingNodeAwaitingTorSetup = false
app.pendingNodeTorValidated = false
app.pendingNodeUrl = ""
app.pendingNodeName = ""
app.pendingNodeTypeName = ""
app.database.globalConfig().setUseTor(false)

val confirmation =
if (fallbackNodeName != null) {
"Switched to clearnet node \"$fallbackNodeName\" and disabled Tor."
} else {
"Disabled Tor."
}
app.alertState =
TaggedItem(
AppAlertState.General(
title = "Tor disabled",
message = confirmation,
),
)
}

LaunchedEffect(app.isTermsAccepted) {
if (!app.isTermsAccepted || startupTorCheckCompleted) {
return@LaunchedEffect
}
startupTorCheckCompleted = true

val globalConfig = app.database.globalConfig()
val useTor = runCatching { globalConfig.useTor() }.getOrDefault(false)
if (!useTor) {
return@LaunchedEffect
}

val mode = parseCoreTorMode(runCatching { globalConfig.get(GlobalConfigKey.TorMode) }.getOrNull())

when (mode) {
TorMode.BUILT_IN -> Unit
TorMode.ORBOT -> {
val reachable = testSocksEndpoint("127.0.0.1", 9050, 1500).isSuccess
if (!reachable) {
startupTorUnavailableMode = TorMode.ORBOT
startupTorUnavailableEndpoint = "127.0.0.1:9050"
}
}
TorMode.EXTERNAL -> {
val host =
runCatching { globalConfig.get(GlobalConfigKey.TorExternalHost) }
.getOrNull()
?.takeIf { it.isNotBlank() }
?: "127.0.0.1"
val port = runCatching { globalConfig.torExternalPort().toInt() }.getOrDefault(9050)
val reachable = testSocksEndpoint(host, port, 1500).isSuccess
if (!reachable) {
startupTorUnavailableMode = TorMode.EXTERNAL
startupTorUnavailableEndpoint = "$host:$port"
}
}
}
}

// compute dark theme based on user preference
val systemDarkTheme = isSystemInDarkTheme()
Expand Down Expand Up @@ -444,6 +543,85 @@ class MainActivity : FragmentActivity() {
)
}
}

if (startupTorUnavailableMode != null) {
AlertDialog(
onDismissRequest = {
startupTorUnavailableMode = null
startupTorUnavailableEndpoint = ""
},
title = {
Text(
when (startupTorUnavailableMode) {
TorMode.ORBOT -> "Orbot is not active"
TorMode.EXTERNAL -> "External Tor proxy is unavailable"
else -> "Tor proxy unavailable"
},
)
},
text = {
Text(
when (startupTorUnavailableMode) {
TorMode.ORBOT ->
"Tor mode is set to Orbot, but $startupTorUnavailableEndpoint is unavailable. " +
"Start Orbot or switch to a clearnet node and disable Tor."
TorMode.EXTERNAL ->
"Tor mode is set to external SOCKS5, but $startupTorUnavailableEndpoint is unavailable. " +
"Fix proxy settings or switch to a clearnet node."
else -> "Tor proxy is unavailable."
},
)
},
confirmButton = {
Column(horizontalAlignment = Alignment.End) {
if (startupTorUnavailableMode == TorMode.ORBOT) {
TextButton(
onClick = {
startupTorUnavailableMode = null
startupTorUnavailableEndpoint = ""
val opened = OrbotPackageHelper.openOrbot(context)
if (!opened) {
OrbotPackageHelper.openInstallPage(context)
}
},
) {
Text("Open Orbot")
}
}
if (startupTorUnavailableMode == TorMode.EXTERNAL) {
TextButton(
onClick = {
startupTorUnavailableMode = null
startupTorUnavailableEndpoint = ""
app.pushRoute(Route.Settings(SettingsRoute.Network))
},
) {
Text("Open network settings")
}
}
TextButton(
onClick = {
startupTorUnavailableMode = null
startupTorUnavailableEndpoint = ""
uiScope.launch {
fallbackToClearnetAndDisableTor()
}
},
) {
Text("Use clearnet node")
}
TextButton(
onClick = {
startupTorUnavailableMode = null
startupTorUnavailableEndpoint = ""
},
) {
Text("Ignore")
}
}
},
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ class WalletManager :
}
}
}
errorAlert = null
}

is WalletManagerReconcileMessage.UpdatedTransactions -> {
Expand All @@ -253,14 +254,17 @@ class WalletManager :
is WalletLoadState.LOADED ->
WalletLoadState.LOADED(message.v1)
}
errorAlert = null
}

is WalletManagerReconcileMessage.ScanComplete -> {
loadState = WalletLoadState.LOADED(message.v1)
errorAlert = null
}

is WalletManagerReconcileMessage.WalletBalanceChanged -> {
balance = message.v1
errorAlert = null
}

is WalletManagerReconcileMessage.UnsignedTransactionsChanged -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ fun SelectedWalletContainer(
app.alertState = TaggedItem(
AppAlertState.WalletDatabaseCorrupted(walletId = e.`id`, error = e.`error`)
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
android.util.Log.e(tag, "something went very wrong", e)

Expand Down
Loading