Skip to content

Commit 2929a74

Browse files
authored
Merge pull request #409 from synonymdev/feat/recovery-mode
Recovery mode
2 parents d89c29e + 62d1bce commit 2929a74

File tree

14 files changed

+742
-4
lines changed

14 files changed

+742
-4
lines changed

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@
106106
<data android:scheme="lnurlc" />
107107
<data android:scheme="lnurlp" />
108108
</intent-filter>
109+
110+
<meta-data
111+
android:name="android.app.shortcuts"
112+
android:resource="@xml/shortcuts" />
109113
</activity>
110114

111115
<service

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import to.bitkit.services.LnurlService
5353
import to.bitkit.services.LnurlWithdrawResponse
5454
import to.bitkit.services.LspNotificationsService
5555
import to.bitkit.services.NodeEventHandler
56+
import to.bitkit.utils.AppError
5657
import to.bitkit.utils.Logger
5758
import to.bitkit.utils.ServiceError
5859
import javax.inject.Inject
@@ -82,6 +83,8 @@ class LightningRepo @Inject constructor(
8283
private val scope = CoroutineScope(bgDispatcher + SupervisorJob())
8384

8485
private var cachedEventHandler: NodeEventHandler? = null
86+
private val _isRecoveryMode = MutableStateFlow(false)
87+
val isRecoveryMode = _isRecoveryMode.asStateFlow()
8588

8689
/**
8790
* Executes the provided operation only if the node is running.
@@ -168,6 +171,12 @@ class LightningRepo @Inject constructor(
168171
customServerUrl: String? = null,
169172
customRgsServerUrl: String? = null,
170173
): Result<Unit> = withContext(bgDispatcher) {
174+
if (_isRecoveryMode.value) {
175+
return@withContext Result.failure(
176+
RecoveryModeException("App in recovery mode, skipping node start")
177+
)
178+
}
179+
171180
val initialLifecycleState = _lightningState.value.nodeLifecycleState
172181
if (initialLifecycleState.isRunningOrStarting()) {
173182
Logger.info("LDK node start skipped, lifecycle state: $initialLifecycleState", context = TAG)
@@ -245,6 +254,10 @@ class LightningRepo @Inject constructor(
245254
}
246255
}
247256

257+
fun setRecoveryMode(enabled: Boolean) {
258+
_isRecoveryMode.value = enabled
259+
}
260+
248261
suspend fun updateGeoBlockState() {
249262
val (isGeoBlocked, shouldBlockLightning) = coreService.checkGeoBlock()
250263
_lightningState.update {
@@ -302,6 +315,7 @@ class LightningRepo @Inject constructor(
302315
nodeLifecycleState = it.nodeLifecycleState,
303316
)
304317
}
318+
setRecoveryMode(false)
305319
Result.success(Unit)
306320
} catch (e: Throwable) {
307321
Logger.error("Wipe storage error", e, context = TAG)
@@ -857,6 +871,8 @@ class LightningRepo @Inject constructor(
857871
}
858872
}
859873

874+
class RecoveryModeException(override val message: String?) : AppError(message = message)
875+
860876
data class LightningState(
861877
val nodeId: String = "",
862878
val nodeStatus: NodeStatus? = null,

app/src/main/java/to/bitkit/repositories/WalletRepo.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ class WalletRepo @Inject constructor(
187187
}
188188

189189
suspend fun createWallet(bip39Passphrase: String?): Result<Unit> = withContext(bgDispatcher) {
190+
lightningRepo.setRecoveryMode(enabled = false)
190191
try {
191192
val mnemonic = generateEntropyMnemonic()
192193
keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic)
@@ -202,6 +203,7 @@ class WalletRepo @Inject constructor(
202203
}
203204

204205
suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?): Result<Unit> = withContext(bgDispatcher) {
206+
lightningRepo.setRecoveryMode(enabled = false)
205207
try {
206208
keychain.saveString(Keychain.Key.BIP39_MNEMONIC.name, mnemonic)
207209
if (bip39Passphrase != null) {

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ import to.bitkit.ui.onboarding.InitializingWalletView
4040
import to.bitkit.ui.onboarding.WalletRestoreErrorView
4141
import to.bitkit.ui.onboarding.WalletRestoreSuccessView
4242
import to.bitkit.ui.screens.CriticalUpdateScreen
43+
import to.bitkit.ui.screens.recovery.RecoveryModeScreen
4344
import to.bitkit.ui.screens.profile.CreateProfileScreen
4445
import to.bitkit.ui.screens.profile.ProfileIntroScreen
46+
import to.bitkit.ui.screens.recovery.RecoveryMnemonicScreen
4547
import to.bitkit.ui.screens.scanner.QrScanningScreen
4648
import to.bitkit.ui.screens.scanner.SCAN_REQUEST_KEY
4749
import to.bitkit.ui.screens.settings.DevSettingsScreen
@@ -409,6 +411,7 @@ private fun RootNavHost(
409411
support(navController)
410412
widgets(navController, settingsViewModel, currencyViewModel)
411413
update()
414+
recoveryMode(navController, appViewModel)
412415

413416
// TODO extract transferNavigation
414417
navigationWithDefaultTransitions<Routes.TransferRoot>(
@@ -1021,6 +1024,27 @@ private fun NavGraphBuilder.update() {
10211024
}
10221025
}
10231026

1027+
private fun NavGraphBuilder.recoveryMode(
1028+
navController: NavHostController,
1029+
appViewModel: AppViewModel
1030+
) {
1031+
composableWithDefaultTransitions<Routes.RecoveryMode> {
1032+
RecoveryModeScreen(
1033+
onNavigateToSeed = {
1034+
navController.navigate(Routes.RecoveryMnemonic)
1035+
},
1036+
appViewModel = appViewModel
1037+
)
1038+
}
1039+
composableWithDefaultTransitions<Routes.RecoveryMnemonic> {
1040+
RecoveryMnemonicScreen(
1041+
onNavigateBack = {
1042+
navController.popBackStack()
1043+
}
1044+
)
1045+
}
1046+
}
1047+
10241048
private fun NavGraphBuilder.support(
10251049
navController: NavHostController,
10261050
) {
@@ -1684,4 +1708,10 @@ sealed interface Routes {
16841708

16851709
@Serializable
16861710
data object CriticalUpdate : Routes
1711+
1712+
@Serializable
1713+
data object RecoveryMode : Routes
1714+
1715+
@Serializable
1716+
data object RecoveryMnemonic : Routes
16871717
}

app/src/main/java/to/bitkit/ui/MainActivity.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,18 +77,19 @@ class MainActivity : FragmentActivity() {
7777
desc = "Channel for LightningNodeService",
7878
importance = NotificationManager.IMPORTANCE_LOW
7979
)
80+
appViewModel.handleDeeplinkIntent(intent)
8081
startForegroundService(Intent(this, LightningNodeService::class.java))
8182
installSplashScreen()
8283
enableAppEdgeToEdge()
83-
appViewModel.handleDeeplinkIntent(intent)
8484
setContent {
8585
AppThemeSurface(
8686
modifier = Modifier.semantics {
8787
testTagsAsResourceId = true // see https://github.com/appium/appium/issues/15138
8888
}
8989
) {
9090
val scope = rememberCoroutineScope()
91-
if (!walletViewModel.walletExists) {
91+
val isRecoveryMode by walletViewModel.isRecoveryMode.collectAsStateWithLifecycle()
92+
if (!walletViewModel.walletExists && !isRecoveryMode) {
9293
OnboardingNav(
9394
startupNavController = rememberNavController(),
9495
scope = scope,
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package to.bitkit.ui.screens.recovery
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.fillMaxSize
7+
import androidx.compose.foundation.layout.fillMaxWidth
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.rememberScrollState
10+
import androidx.compose.foundation.verticalScroll
11+
import androidx.compose.material3.CircularProgressIndicator
12+
import androidx.compose.material3.MaterialTheme
13+
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.collectAsState
15+
import androidx.compose.runtime.getValue
16+
import androidx.compose.ui.Alignment
17+
import androidx.compose.ui.Modifier
18+
import androidx.compose.ui.draw.clip
19+
import androidx.compose.ui.platform.testTag
20+
import androidx.compose.ui.res.stringResource
21+
import androidx.compose.ui.tooling.preview.Preview
22+
import androidx.compose.ui.unit.dp
23+
import androidx.hilt.navigation.compose.hiltViewModel
24+
import to.bitkit.R
25+
import to.bitkit.ui.components.BodyM
26+
import to.bitkit.ui.components.FillHeight
27+
import to.bitkit.ui.components.PrimaryButton
28+
import to.bitkit.ui.components.VerticalSpacer
29+
import to.bitkit.ui.scaffold.AppTopBar
30+
import to.bitkit.ui.settings.backups.MnemonicWordsGrid
31+
import to.bitkit.ui.shared.util.screen
32+
import to.bitkit.ui.theme.AppThemeSurface
33+
import to.bitkit.ui.theme.Colors
34+
import to.bitkit.ui.utils.withAccent
35+
36+
@Composable
37+
fun RecoveryMnemonicScreen(
38+
onNavigateBack: () -> Unit,
39+
recoveryMnemonicViewModel: RecoveryMnemonicViewModel = hiltViewModel(),
40+
) {
41+
val uiState by recoveryMnemonicViewModel.uiState.collectAsState()
42+
43+
Content(
44+
uiState = uiState,
45+
onNavigateBack = onNavigateBack,
46+
)
47+
}
48+
49+
@Composable
50+
private fun Content(
51+
uiState: RecoveryMnemonicUiState,
52+
onNavigateBack: () -> Unit,
53+
) {
54+
Column(
55+
modifier = Modifier
56+
.screen()
57+
) {
58+
AppTopBar(
59+
titleText = stringResource(R.string.security__mnemonic_phrase),
60+
onBackClick = onNavigateBack,
61+
)
62+
63+
VerticalSpacer(16.dp)
64+
65+
if (uiState.isLoading) {
66+
// Loading state
67+
Box(
68+
contentAlignment = Alignment.Center,
69+
modifier = Modifier
70+
.fillMaxSize()
71+
.weight(1f)
72+
) {
73+
CircularProgressIndicator(
74+
modifier = Modifier.padding(16.dp),
75+
color = MaterialTheme.colorScheme.primary,
76+
)
77+
}
78+
} else {
79+
// Content state
80+
Column(
81+
modifier = Modifier
82+
.weight(1f)
83+
.padding(horizontal = 16.dp)
84+
.verticalScroll(rememberScrollState())
85+
) {
86+
BodyM(
87+
text = stringResource(R.string.security__mnemonic_write).replace(
88+
"{length}",
89+
uiState.mnemonicWords.count().toString()
90+
),
91+
color = Colors.White64
92+
)
93+
94+
VerticalSpacer(16.dp)
95+
96+
Box(
97+
modifier = Modifier
98+
.fillMaxWidth()
99+
.clip(MaterialTheme.shapes.medium)
100+
.background(color = Colors.White10)
101+
.padding(32.dp)
102+
.testTag("backup_mnemonic_words_box")
103+
) {
104+
MnemonicWordsGrid(
105+
actualWords = uiState.mnemonicWords,
106+
showMnemonic = true,
107+
blurRadius = 0f,
108+
)
109+
}
110+
111+
// Passphrase section (if available)
112+
if (uiState.passphrase.isNotEmpty()) {
113+
VerticalSpacer(32.dp)
114+
115+
Column {
116+
BodyM(text = stringResource(R.string.security__pass_text), color = Colors.White64)
117+
118+
VerticalSpacer(16.dp)
119+
120+
BodyM(
121+
text = stringResource(R.string.security__pass_recovery, uiState.passphrase)
122+
.replace("{passphrase}", uiState.passphrase)
123+
.withAccent(accentColor = Colors.White64),
124+
color = Colors.White
125+
)
126+
}
127+
}
128+
129+
FillHeight()
130+
131+
VerticalSpacer(32.dp)
132+
133+
PrimaryButton(
134+
text = stringResource(R.string.common__back),
135+
onClick = onNavigateBack,
136+
)
137+
138+
VerticalSpacer(16.dp)
139+
}
140+
}
141+
}
142+
}
143+
144+
@Preview(showSystemUi = true)
145+
@Composable
146+
private fun LoadingPreview() {
147+
AppThemeSurface {
148+
Content(
149+
uiState = RecoveryMnemonicUiState(isLoading = true),
150+
onNavigateBack = {},
151+
)
152+
}
153+
}
154+
155+
@Preview(showSystemUi = true)
156+
@Composable
157+
private fun ContentPreview12Words() {
158+
AppThemeSurface {
159+
Content(
160+
uiState = RecoveryMnemonicUiState(
161+
isLoading = false,
162+
mnemonicWords = listOf(
163+
"abandon", "ability", "able", "about", "above", "absent",
164+
"absorb", "abstract", "absurd", "abuse", "access", "accident",
165+
),
166+
passphrase = "my_secret_passphrase"
167+
),
168+
onNavigateBack = {},
169+
)
170+
}
171+
}
172+
173+
@Preview(showSystemUi = true)
174+
@Composable
175+
private fun ContentPreview24Words() {
176+
AppThemeSurface {
177+
Content(
178+
uiState = RecoveryMnemonicUiState(
179+
isLoading = false,
180+
mnemonicWords = listOf(
181+
"abandon", "ability", "able", "about", "above", "absent",
182+
"absorb", "abstract", "absurd", "abuse", "access", "accident",
183+
"account", "accuse", "achieve", "acid", "acoustic", "acquire",
184+
"across", "act", "action", "actor", "actress", "actual"
185+
),
186+
passphrase = "my_secret_passphrase"
187+
),
188+
onNavigateBack = {},
189+
)
190+
}
191+
}

0 commit comments

Comments
 (0)