diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2574afff7..21208b3d6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import com.android.build.gradle.internal.api.BaseVariantOutputImpl +import io.gitlab.arturbosch.detekt.Detekt import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent import org.jetbrains.kotlin.compose.compiler.gradle.ComposeFeatureFlag @@ -34,6 +36,8 @@ val keystoreProperties by lazy { keystoreProperties } +val locales = listOf("en", "ar", "ca", "cs", "de", "el", "es", "fr", "it", "nl", "pl", "pt", "ru") + android { namespace = "to.bitkit" compileSdk = 35 @@ -49,6 +53,7 @@ android { } buildConfigField("boolean", "E2E", System.getenv("E2E")?.toBoolean()?.toString() ?: "false") buildConfigField("boolean", "GEO", System.getenv("GEO")?.toBoolean()?.toString() ?: "true") + buildConfigField("String", "LOCALES", "\"${locales.joinToString(",")}\"") } flavorDimensions += "network" @@ -131,7 +136,7 @@ android { } androidResources { @Suppress("UnstableApiUsage") - localeFilters.addAll(listOf("en", "ar", "ca", "cs", "de", "el", "es", "fr", "it", "nl", "pl", "pt", "ru")) + localeFilters.addAll(locales) @Suppress("UnstableApiUsage") generateLocaleConfig = true } @@ -153,7 +158,7 @@ android { applicationVariants.all { val variant = this outputs - .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .map { it as BaseVariantOutputImpl } .forEach { output -> val apkName = "bitkit-android-${defaultConfig.versionCode}-${variant.name}.apk" output.outputFileName = apkName @@ -169,17 +174,6 @@ composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") } -tasks.withType().configureEach { - ignoreFailures = true - reports { - html.required.set(true) - sarif.required.set(true) - md.required.set(false) - txt.required.set(false) - xml.required.set(false) - } -} - dependencies { implementation(fileTree("libs") { include("*.aar") }) implementation(libs.jna) { artifact { type = "aar" } } @@ -281,6 +275,19 @@ room { schemaDirectory("$projectDir/schemas") } +// region Tasks + +tasks.withType().configureEach { + ignoreFailures = true + reports { + html.required.set(true) + sarif.required.set(true) + md.required.set(false) + txt.required.set(false) + xml.required.set(false) + } +} + tasks.withType { testLogging { events( @@ -297,3 +304,12 @@ tasks.withType { showStackTraces = true } } + +// JDK 21+ prints warnings when ByteBuddy loads a dynamic Java agent during tests. +// Our test stack triggers this automatically. +// Explicitly enabling dynamic agent loading silences the warning without altering behavior. +tasks.withType().configureEach { + jvmArgs("-XX:+EnableDynamicAgentLoading") +} + +// endregion diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index a130bdd36..56314ea81 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -64,7 +64,6 @@ ConstructorParameterNaming:AddressChecker.kt$TxStatus$val block_height: Int? = null ConstructorParameterNaming:AddressChecker.kt$TxStatus$val block_time: Long? = null CyclomaticComplexMethod:ActivityDetailScreen.kt$@Composable private fun ActivityDetailContent( item: Activity, tags: List<String>, onRemoveTag: (String) -> Unit, onAddTagClick: () -> Unit, onClickBoost: () -> Unit, onExploreClick: (String) -> Unit, onCopy: (String) -> Unit, ) - CyclomaticComplexMethod:ActivityIcon.kt$@Composable fun ActivityIcon( activity: Activity, size: Dp = 32.dp, modifier: Modifier = Modifier, ) CyclomaticComplexMethod:ActivityListGrouped.kt$private fun groupActivityItems(activityItems: List<Activity>): List<Any> CyclomaticComplexMethod:ActivityRow.kt$@Composable fun ActivityRow( item: Activity, onClick: (String) -> Unit, testTag: String, ) CyclomaticComplexMethod:ActivityRow.kt$@Composable private fun TransactionStatusText( txType: PaymentType, isLightning: Boolean, status: PaymentState?, isTransfer: Boolean, ) @@ -79,8 +78,6 @@ CyclomaticComplexMethod:HealthRepo.kt$HealthRepo$private fun collectState() CyclomaticComplexMethod:HomeScreen.kt$@Composable fun HomeScreen( mainUiState: MainUiState, drawerState: DrawerState, rootNavController: NavController, walletNavController: NavHostController, settingsViewModel: SettingsViewModel, walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, homeViewModel: HomeViewModel = hiltViewModel(), ) CyclomaticComplexMethod:LightningService.kt$LightningService$private fun logEvent(event: Event) - CyclomaticComplexMethod:ReceiveQrScreen.kt$@Composable fun ReceiveQrScreen( cjitInvoice: MutableState<String?>, cjitActive: MutableState<Boolean>, walletState: MainUiState, onCjitToggle: (Boolean) -> Unit, onClickEditInvoice: () -> Unit, onClickReceiveOnSpending: () -> Unit, modifier: Modifier = Modifier, ) - CyclomaticComplexMethod:RestoreWalletScreen.kt$@Composable fun RestoreWalletView( onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, ) CyclomaticComplexMethod:SendSheet.kt$@Composable fun SendSheet( appViewModel: AppViewModel, walletViewModel: WalletViewModel, startDestination: SendRoute = SendRoute.Recipient, ) CyclomaticComplexMethod:SettingsButtonRow.kt$@Composable fun SettingsButtonRow( title: String, modifier: Modifier = Modifier, subtitle: String? = null, value: SettingsButtonValue = SettingsButtonValue.None, description: String? = null, iconRes: Int? = null, iconTint: Color = Color.Unspecified, iconSize: Dp = 32.dp, maxLinesSubtitle: Int = Int.MAX_VALUE, enabled: Boolean = true, loading: Boolean = false, onClick: () -> Unit, ) CyclomaticComplexMethod:Slider.kt$@Composable fun StepSlider( value: Int, steps: List<Int>, onValueChange: (Int) -> Unit, modifier: Modifier = Modifier, ) @@ -100,10 +97,7 @@ EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$orderPaymentConfirmed EnumNaming:BlocktankNotificationType.kt$BlocktankNotificationType$wakeToTimeout ForbiddenComment:ActivityDetailScreen.kt$/* TODO: Implement assign functionality */ - ForbiddenComment:ActivityListViewModel.kt$ActivityListViewModel$// TODO: sync only on specific events for better performance ForbiddenComment:ActivityRow.kt$// TODO: calculate confirmsIn text - ForbiddenComment:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$// TODO: get from actual repository state - ForbiddenComment:BackupRepo.kt$BackupRepo$// TODO: Add other backup categories as they get implemented: ForbiddenComment:BoostTransactionViewModel.kt$BoostTransactionUiState$// TODO: Implement dynamic time estimation ForbiddenComment:ChannelStatusView.kt$// TODO: handle closed channels marking & detection ForbiddenComment:ContentView.kt$// TODO: display as sheet @@ -151,7 +145,6 @@ LambdaParameterInRestartableEffect:SpendingAmountScreen.kt$toastException LargeClass:AppViewModel.kt$AppViewModel : ViewModel LargeClass:LightningRepo.kt$LightningRepo - LongMethod:ActivityRepo.kt$ActivityRepo$private suspend fun syncTagsMetadata() LongMethod:AppViewModel.kt$AppViewModel$private fun observeLdkNodeEvents() LongMethod:AppViewModel.kt$AppViewModel$private suspend fun proceedWithPayment() LongMethod:ContentView.kt$private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, currencyViewModel: CurrencyViewModel, ) @@ -176,8 +169,6 @@ MagicNumber:AddressViewerScreen.kt$250000L MagicNumber:AddressViewerScreen.kt$50000L MagicNumber:AddressViewerViewModel.kt$AddressViewerViewModel$300 - MagicNumber:AllActivityScreen.kt$0xFF161616 - MagicNumber:AllActivityScreen.kt$0xFF1e1e1e MagicNumber:AppStatus.kt$0.4f MagicNumber:AppViewModel.kt$AppViewModel$1000 MagicNumber:AppViewModel.kt$AppViewModel$250 @@ -187,16 +178,11 @@ MagicNumber:ArticleModel.kt$60 MagicNumber:AutoReadClipboardHandler.kt$1000 MagicNumber:BackupNavSheetViewModel.kt$BackupNavSheetViewModel$200 - MagicNumber:BackupRepo.kt$BackupRepo$60000 MagicNumber:BackupsViewModel.kt$BackupsViewModel$500 MagicNumber:BiometricsView.kt$5 - MagicNumber:Bip39Utils.kt$12 - MagicNumber:Bip39Utils.kt$24 - MagicNumber:Bip39Utils.kt$8 MagicNumber:ChangePinConfirmScreen.kt$500 MagicNumber:ChannelDetailScreen.kt$1.5f MagicNumber:ChannelOrdersScreen.kt$10 - MagicNumber:ChannelOrdersScreen.kt$100 MagicNumber:ConfirmMnemonicScreen.kt$300 MagicNumber:ContentView.kt$100 MagicNumber:ContentView.kt$500 @@ -215,7 +201,6 @@ MagicNumber:InitializingWalletView.kt$500 MagicNumber:InitializingWalletView.kt$99.9 MagicNumber:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$500 - MagicNumber:Logger.kt$Logger$4 MagicNumber:NewsService.kt$NewsService$10 MagicNumber:OnboardingSlidesScreen.kt$3 MagicNumber:OnboardingSlidesScreen.kt$4 @@ -232,7 +217,6 @@ MagicNumber:ReceiveQrScreen.kt$17.33f MagicNumber:ReceiveQrScreen.kt$32 MagicNumber:RestoreWalletScreen.kt$12 - MagicNumber:RestoreWalletScreen.kt$24 MagicNumber:SavingsConfirmScreen.kt$300 MagicNumber:SavingsProgressScreen.kt$2500 MagicNumber:SavingsProgressScreen.kt$5000 @@ -265,18 +249,6 @@ MatchingDeclarationName:SavingsProgressScreen.kt$SavingsProgressState MatchingDeclarationName:SettingsButtonRow.kt$SettingsButtonValue MaxLineLength:ActivityDetailScreen.kt$description = "Unable to increase the fee any further. Otherwise, it will exceed half the current input balance" - MaxLineLength:Bip39Test.kt$Bip39Test$"AbAnDoN abandon ABANDON abandon abandon abandon abandon abandon abandon abandon abandon about".toWordList().validBip39Checksum() - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" to true - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false - MaxLineLength:Bip39Test.kt$Bip39Test$"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false - MaxLineLength:Bip39Test.kt$Bip39Test$"dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic" to true - MaxLineLength:Bip39Test.kt$Bip39Test$"legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title" to true - MaxLineLength:Bip39Test.kt$Bip39Test$"letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless" to true - MaxLineLength:Bip39Test.kt$Bip39Test$val validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - MaxLineLength:Bip39Utils.kt$setOf("abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance", "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become", "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog", "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk", "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify", "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", "coil", "coin", "collect", "color", "column", "combine", "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper", "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay", "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk", "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide", "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft", "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil", "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit", "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint", "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault", "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field", "figure", "file", "film", "filter", "final", "find", "fine", "finger", "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly", "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil", "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel", "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment", "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle", "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue", "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass", "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun", "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital", "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose", "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial", "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest", "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel", "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup", "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law", "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend", "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", "life", "lift", "light", "like", "limb", "limit", "link", "lion", "liquid", "list", "little", "live", "lizard", "load", "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop", "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet", "maid", "mail", "main", "major", "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin", "marine", "market", "marriage", "mask", "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure", "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message", "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake", "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning", "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music", "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative", "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", "noble", "noise", "nominee", "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey", "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", "often", "oil", "okay", "old", "olive", "olympic", "omit", "once", "one", "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit", "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over", "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper", "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut", "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical", "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet", "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare", "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", "prison", "private", "prize", "problem", "process", "produce", "profit", "program", "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil", "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz", "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid", "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle", "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove", "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire", "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid", "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room", "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness", "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say", "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea", "search", "season", "seat", "second", "secret", "section", "security", "seed", "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence", "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine", "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side", "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", "sister", "situate", "six", "size", "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan", "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social", "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject", "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim", "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", "tennis", "tent", "term", "test", "text", "thank", "that", "theme", "then", "theory", "there", "they", "thing", "this", "thought", "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger", "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token", "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist", "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree", "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try", "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical", "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown", "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", "usage", "use", "used", "useful", "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle", "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view", "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote", "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave", "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat", "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", "winner", "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo") MaxLineLength:BlocksEditScreen.kt$enabled = blocksPreferences.run { showBlock || showTime || showDate || showTransactions || showSize || showSource } MaxLineLength:BlocktankRegtestScreen.kt$"Initiating channel close with fundingTxId: $fundingTxId, vout: $vout, forceCloseAfter: $forceCloseAfter" MaxLineLength:BlocktankRepo.kt$BlocktankRepo$"Buying channel with lspBalanceSat: $receivingBalanceSats, channelExpiryWeeks: $channelExpiryWeeks, options: $options" @@ -302,8 +274,6 @@ MaxLineLength:SettingsScreen.kt$if (newValue) R.string.settings__dev_enabled_message else R.string.settings__dev_disabled_message MaxLineLength:WeatherService.kt$WeatherService$val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE MaximumLineLength:ActivityDetailScreen.kt$ - MaximumLineLength:Bip39Test.kt$Bip39Test$ - MaximumLineLength:Bip39Utils.kt$ MaximumLineLength:BlocksEditScreen.kt$ MaximumLineLength:BlocktankRegtestScreen.kt$ MaximumLineLength:BlocktankRepo.kt$BlocktankRepo$ @@ -371,7 +341,6 @@ ModifierMissing:ReportIssueResultScreen.kt$ReportIssueResultScreen ModifierMissing:ReportIssueScreen.kt$ReportIssueContent ModifierMissing:RestoreWalletScreen.kt$MnemonicInputField - ModifierMissing:RestoreWalletScreen.kt$RestoreWalletView ModifierMissing:SavingsAdvancedScreen.kt$ChannelItem ModifierMissing:SavingsAvailabilityScreen.kt$SavingsAvailabilityScreen ModifierMissing:SavingsIntroScreen.kt$SavingsIntroScreen @@ -429,8 +398,6 @@ ParameterNaming:PinConfirmScreen.kt$onPinConfirmed ParameterNaming:QrCodeImage.kt$onBitmapGenerated ParameterNaming:ReceiveAmountScreen.kt$onCjitCreated - ParameterNaming:RestoreWalletScreen.kt$onPositionChanged - ParameterNaming:RestoreWalletScreen.kt$onValueChanged ParameterNaming:SpendingAdvancedScreen.kt$onOrderCreated ParameterNaming:SpendingAmountScreen.kt$onOrderCreated ParameterNaming:TransactionSpeedSettingsScreen.kt$onSpeedSelected @@ -440,18 +407,14 @@ PreviewPublic:EmptyWalletView.kt$EmptyStateViewPreview PreviewPublic:HighlightLabel.kt$FlexibleLogoPreview PreviewPublic:HighlightLabel.kt$LongTextLogoPreview - PreviewPublic:RestoreWalletScreen.kt$RestoreWalletViewPreview PreviewPublic:SplashScreen.kt$SplashScreenPreview PrintStackTrace:ShareSheet.kt$e ReturnCount:AppViewModel.kt$AppViewModel$private suspend fun handleSanityChecks(amountSats: ULong) - ReturnCount:ChannelStatusView.kt$@Composable private fun getStatusInfo( channel: ChannelUi, blocktankOrder: IBtOrder?, ): StatusInfo ReturnCount:FcmService.kt$FcmService$private fun decryptPayload(response: EncryptedNotification) ReturnCount:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$private fun findUpdatedChannel( currentChannel: ChannelDetails, allChannels: List<ChannelDetails>, ): ChannelDetails? - SpreadOperator:RestoreWalletScreen.kt$(*Array(24) { "" }) SwallowedException:Crypto.kt$Crypto$e: Exception TooGenericExceptionCaught:ActivityDetailViewModel.kt$ActivityDetailViewModel$e: Exception TooGenericExceptionCaught:ActivityDetailViewModel.kt$ActivityDetailViewModel$e: Throwable - TooGenericExceptionCaught:ActivityListViewModel.kt$ActivityListViewModel$e: Exception TooGenericExceptionCaught:ActivityRepo.kt$ActivityRepo$e: Exception TooGenericExceptionCaught:AddressChecker.kt$AddressChecker$e: Exception TooGenericExceptionCaught:AppViewModel.kt$AppViewModel$e: Exception @@ -473,7 +436,6 @@ TooGenericExceptionCaught:LightningConnectionsViewModel.kt$LightningConnectionsViewModel$e: Exception TooGenericExceptionCaught:LightningRepo.kt$LightningRepo$e: Throwable TooGenericExceptionCaught:LightningService.kt$LightningService$e: Exception - TooGenericExceptionCaught:Logger.kt$Logger$e: Throwable TooGenericExceptionCaught:LogsRepo.kt$LogsRepo$e: Exception TooGenericExceptionCaught:LogsViewModel.kt$LogsViewModel$e: Exception TooGenericExceptionCaught:NewTransactionSheetDetails.kt$NewTransactionSheetDetails.Companion$e: Exception @@ -493,27 +455,18 @@ TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("HTTP error: ${response.status}") TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("LNURL channel error: ${parsedResponse.reason}") TooGenericExceptionThrown:LnurlService.kt$LnurlService$throw Exception("LNURL error: ${withdrawResponse.reason}") - TooManyFunctions:ActivityListViewModel.kt$ActivityListViewModel : ViewModel TooManyFunctions:ActivityRepo.kt$ActivityRepo TooManyFunctions:AppViewModel.kt$AppViewModel : ViewModel TooManyFunctions:BackupNavSheetViewModel.kt$BackupNavSheetViewModel : ViewModel - TooManyFunctions:BackupRepo.kt$BackupRepo TooManyFunctions:BlocktankRepo.kt$BlocktankRepo - TooManyFunctions:BoostTransactionViewModel.kt$BoostTransactionViewModel : ViewModel TooManyFunctions:CacheStore.kt$CacheStore - TooManyFunctions:ChannelOrdersScreen.kt$to.bitkit.ui.settings.ChannelOrdersScreen.kt TooManyFunctions:ContentView.kt$to.bitkit.ui.ContentView.kt TooManyFunctions:CoreService.kt$ActivityService TooManyFunctions:CoreService.kt$BlocktankService TooManyFunctions:DevSettingsViewModel.kt$DevSettingsViewModel : ViewModel - TooManyFunctions:ElectrumConfigViewModel.kt$ElectrumConfigViewModel : ViewModel - TooManyFunctions:HomeViewModel.kt$HomeViewModel : ViewModel - TooManyFunctions:LightningConnectionsViewModel.kt$LightningConnectionsViewModel : ViewModel TooManyFunctions:LightningRepo.kt$LightningRepo TooManyFunctions:LightningService.kt$LightningService : BaseCoroutineScope - TooManyFunctions:Logger.kt$Logger TooManyFunctions:SettingsViewModel.kt$SettingsViewModel : ViewModel - TooManyFunctions:TOS.kt$to.bitkit.ui.onboarding.TOS.kt TooManyFunctions:TagMetadataDao.kt$TagMetadataDao TooManyFunctions:Text.kt$to.bitkit.ui.components.Text.kt TooManyFunctions:TransferViewModel.kt$TransferViewModel : ViewModel diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index dc5a84990..09b9656dd 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -11,6 +11,7 @@ import to.bitkit.env.Env import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.CoinSelectionPreference import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.SettingsBackupV1 import to.bitkit.models.Suggestion import to.bitkit.models.TransactionSpeed import to.bitkit.utils.Logger @@ -30,6 +31,14 @@ class SettingsStore @Inject constructor( val data: Flow = store.data + suspend fun restoreFromBackup(payload: SettingsBackupV1) = + runCatching { + val data = payload.settings.resetPin() + store.updateData { data } + }.onSuccess { + Logger.debug("Restored settings", TAG) + } + suspend fun update(transform: (SettingsData) -> SettingsData) { store.updateData(transform) } @@ -61,6 +70,7 @@ class SettingsStore @Inject constructor( } companion object { + private const val TAG = "SettingsStore" private const val MAX_LAST_USED_TAGS = 10 } } diff --git a/app/src/main/java/to/bitkit/data/WidgetsStore.kt b/app/src/main/java/to/bitkit/data/WidgetsStore.kt index 2a7fab9ae..dd43fc8ae 100644 --- a/app/src/main/java/to/bitkit/data/WidgetsStore.kt +++ b/app/src/main/java/to/bitkit/data/WidgetsStore.kt @@ -15,6 +15,7 @@ import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.serializers.WidgetsSerializer import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.WidgetsBackupV1 import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.CalculatorValues import to.bitkit.models.widget.FactsPreferences @@ -43,9 +44,13 @@ class WidgetsStore @Inject constructor( val weatherFlow: Flow = data.map { it.weather } val priceFlow: Flow = data.map { it.price } - suspend fun update(transform: (WidgetsData) -> WidgetsData) { - store.updateData(transform) - } + suspend fun restoreFromBackup(payload: WidgetsBackupV1) = + runCatching { + val data = payload.widgets + store.updateData { data } + }.onSuccess { + Logger.debug("Restored widgets", TAG) + } suspend fun updateCalculatorValues(calculatorValues: CalculatorValues) { store.updateData { @@ -127,16 +132,16 @@ class WidgetsStore @Inject constructor( suspend fun addWidget(type: WidgetType) { if (store.data.first().widgets.map { it.type }.contains(type)) return - store.updateData { - it.copy(widgets = (it.widgets + WidgetWithPosition(type = type)).sortedBy { it.position }) + store.updateData { data -> + data.copy(widgets = (data.widgets + WidgetWithPosition(type = type)).sortedBy { it.position }) } } suspend fun deleteWidget(type: WidgetType) { if (!store.data.first().widgets.map { it.type }.contains(type)) return - store.updateData { - it.copy(widgets = it.widgets.filterNot { it.type == type }) + store.updateData { data -> + data.copy(widgets = data.widgets.filterNot { it.type == type }) } } @@ -145,6 +150,10 @@ class WidgetsStore @Inject constructor( it.copy(widgets = widgets) } } + + companion object { + private const val TAG = "WidgetsStore" + } } @Serializable diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 342e4eeba..47a96b86f 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -18,6 +18,7 @@ internal object Env { const val isE2eTest = BuildConfig.E2E const val isGeoblockingEnabled = BuildConfig.GEO val network = Network.valueOf(BuildConfig.NETWORK) + val locales = BuildConfig.LOCALES.split(",") val walletSyncIntervalSecs = 10_uL // TODO review val platform = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" const val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" @@ -116,10 +117,10 @@ internal object Env { Logger.info("App storage path: $path") } - val logDir: String + val logDir: File get() { require(::appStoragePath.isInitialized) - return File(appStoragePath).resolve("logs").ensureDir().path + return File(appStoragePath).resolve("logs").ensureDir() } fun ldkStoragePath(walletIndex: Int) = storagePathOf(walletIndex, network.name.lowercase(), "ldk") diff --git a/app/src/main/java/to/bitkit/ext/Activities.kt b/app/src/main/java/to/bitkit/ext/Activities.kt index 15338da9c..ae0e9c22a 100644 --- a/app/src/main/java/to/bitkit/ext/Activities.kt +++ b/app/src/main/java/to/bitkit/ext/Activities.kt @@ -9,6 +9,11 @@ fun Activity.rawId(): String = when (this) { is Activity.Onchain -> v1.id } +fun Activity.txType(): PaymentType = when (this) { + is Activity.Lightning -> v1.txType + is Activity.Onchain -> v1.txType +} + /** * Calculates the total value of an activity based on its type. * diff --git a/app/src/main/java/to/bitkit/ext/DateTime.kt b/app/src/main/java/to/bitkit/ext/DateTime.kt index e67045cd1..d15dde622 100644 --- a/app/src/main/java/to/bitkit/ext/DateTime.kt +++ b/app/src/main/java/to/bitkit/ext/DateTime.kt @@ -3,6 +3,12 @@ package to.bitkit.ext import android.icu.text.DateFormat +import android.icu.text.DisplayContext +import android.icu.text.NumberFormat +import android.icu.text.RelativeDateTimeFormatter +import android.icu.text.RelativeDateTimeFormatter.AbsoluteUnit +import android.icu.text.RelativeDateTimeFormatter.Direction +import android.icu.text.RelativeDateTimeFormatter.RelativeUnit import android.icu.util.ULocale import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate @@ -48,11 +54,50 @@ fun Long.toDateUTC(): String { fun Long.toLocalizedTimestamp(): String { val uLocale = ULocale.forLocale(Locale.US) val formatter = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT, uLocale) + ?: return SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.US).format(Date(this)) return formatter.format(Date(this)) } +@Suppress("LongMethod") +fun Long.toRelativeTimeString( + locale: Locale = Locale.getDefault(), + clock: Clock = Clock.System, +): String { + val now = nowMillis(clock) + val diffMillis = now - this + + val uLocale = ULocale.forLocale(locale) + val numberFormat = NumberFormat.getNumberInstance(uLocale)?.apply { maximumFractionDigits = 0 } + + val formatter = RelativeDateTimeFormatter.getInstance( + uLocale, + numberFormat, + RelativeDateTimeFormatter.Style.LONG, + DisplayContext.CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE, + ) ?: return toLocalizedTimestamp() + + val seconds = diffMillis / Factor.MILLIS_TO_SECONDS + val minutes = seconds / Factor.SECONDS_TO_MINUTES + val hours = minutes / Factor.MINUTES_TO_HOURS + val days = hours / Factor.HOURS_TO_DAYS + val weeks = days / Factor.DAYS_TO_WEEKS + val months = days / Factor.DAYS_TO_MONTHS + val years = days / Factor.DAYS_TO_YEARS + + return when { + seconds < Threshold.SECONDS -> formatter.format(Direction.PLAIN, AbsoluteUnit.NOW) + minutes < Threshold.MINUTES -> formatter.format(minutes, Direction.LAST, RelativeUnit.MINUTES) + hours < Threshold.HOURS -> formatter.format(hours, Direction.LAST, RelativeUnit.HOURS) + days < Threshold.YESTERDAY -> formatter.format(Direction.LAST, AbsoluteUnit.DAY) + days < Threshold.DAYS -> formatter.format(days, Direction.LAST, RelativeUnit.DAYS) + weeks < Threshold.WEEKS -> formatter.format(weeks, Direction.LAST, RelativeUnit.WEEKS) + months < Threshold.MONTHS -> formatter.format(months, Direction.LAST, RelativeUnit.MONTHS) + else -> formatter.format(years, Direction.LAST, RelativeUnit.YEARS) + } +} + fun getDaysInMonth(month: LocalDate): List { - val firstDayOfMonth = LocalDate(month.year, month.month, CalendarConstants.FIRST_DAY_OF_MONTH) + val firstDayOfMonth = LocalDate(month.year, month.month, Constants.FIRST_DAY_OF_MONTH) val daysInMonth = month.month.length(isLeapYear(month.year)) // Get the day of week for the first day (1 = Monday, 7 = Sunday) @@ -70,7 +115,7 @@ fun getDaysInMonth(month: LocalDate): List { } // Add all days of the month - for (day in CalendarConstants.FIRST_DAY_OF_MONTH..daysInMonth) { + for (day in Constants.FIRST_DAY_OF_MONTH..daysInMonth) { days.add(LocalDate(month.year, month.month, day)) } @@ -83,49 +128,43 @@ fun getDaysInMonth(month: LocalDate): List { } fun isLeapYear(year: Int): Boolean { - return (year % CalendarConstants.LEAP_YEAR_DIVISOR_4 == 0 && year % CalendarConstants.LEAP_YEAR_DIVISOR_100 != 0) || - (year % CalendarConstants.LEAP_YEAR_DIVISOR_400 == 0) + return (year % Constants.LEAP_YEAR_DIVISOR_4 == 0 && year % Constants.LEAP_YEAR_DIVISOR_100 != 0) || + (year % Constants.LEAP_YEAR_DIVISOR_400 == 0) } -fun isDateInRange(dateMillis: Long, startMillis: Long?, endMillis: Long?): Boolean { +fun isDateInRange( + dateMillis: Long, + startMillis: Long?, + endMillis: Long?, + zone: TimeZone = TimeZone.currentSystemDefault(), +): Boolean { if (startMillis == null) return false val end = endMillis ?: startMillis - val normalizedDate = kotlinx.datetime.Instant.fromEpochMilliseconds(dateMillis) - .toLocalDateTime(TimeZone.currentSystemDefault()).date - val normalizedStart = kotlinx.datetime.Instant.fromEpochMilliseconds(startMillis) - .toLocalDateTime(TimeZone.currentSystemDefault()).date - val normalizedEnd = kotlinx.datetime.Instant.fromEpochMilliseconds(end) - .toLocalDateTime(TimeZone.currentSystemDefault()).date + val normalizedDate = kotlinx.datetime.Instant.fromEpochMilliseconds(dateMillis).toLocalDateTime(zone).date + val normalizedStart = kotlinx.datetime.Instant.fromEpochMilliseconds(startMillis).toLocalDateTime(zone).date + val normalizedEnd = kotlinx.datetime.Instant.fromEpochMilliseconds(end).toLocalDateTime(zone).date - return normalizedDate >= normalizedStart && normalizedDate <= normalizedEnd + return normalizedDate in normalizedStart..normalizedEnd } -fun LocalDate.toMonthYearString(): String { - val formatter = SimpleDateFormat(DatePattern.MONTH_YEAR_FORMAT, Locale.getDefault()) +fun LocalDate.toMonthYearString(locale: Locale = Locale.getDefault()): String { + val formatter = SimpleDateFormat(DatePattern.MONTH_YEAR_FORMAT, locale) val calendar = Calendar.getInstance() - calendar.set(year, monthNumber - CalendarConstants.MONTH_INDEX_OFFSET, CalendarConstants.FIRST_DAY_OF_MONTH) + calendar.set(year, monthNumber - CalendarConstants.MONTH_INDEX_OFFSET, Constants.FIRST_DAY_OF_MONTH) return formatter.format(calendar.time) } fun LocalDate.minusMonths(months: Int): LocalDate = - this.toJavaLocalDate() - .minusMonths(months.toLong()) - .withDayOfMonth(1) // Always use first day of month for display + toJavaLocalDate().minusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display .toKotlinLocalDate() fun LocalDate.plusMonths(months: Int): LocalDate = - this.toJavaLocalDate() - .plusMonths(months.toLong()) - .withDayOfMonth(1) // Always use first day of month for display + toJavaLocalDate().plusMonths(months.toLong()).withDayOfMonth(1) // Always use first day of month for display .toKotlinLocalDate() -fun LocalDate.endOfDay(): Long { - return this.atStartOfDayIn(TimeZone.currentSystemDefault()) - .plus(1.days) - .minus(1.milliseconds) - .toEpochMilliseconds() -} +fun LocalDate.endOfDay(zone: TimeZone = TimeZone.currentSystemDefault()): Long = + atStartOfDayIn(zone).plus(1.days).minus(1.milliseconds).toEpochMilliseconds() fun utcDateFormatterOf(pattern: String) = SimpleDateFormat(pattern, Locale.US).apply { timeZone = java.util.TimeZone.getTimeZone("UTC") @@ -147,11 +186,39 @@ object DatePattern { const val WEEKDAY_FORMAT = "EEE" } -object CalendarConstants { +private object Constants { + // Calendar + const val FIRST_DAY_OF_MONTH = 1 + + // Leap year calculation + const val LEAP_YEAR_DIVISOR_4 = 4 + const val LEAP_YEAR_DIVISOR_100 = 100 + const val LEAP_YEAR_DIVISOR_400 = 400 +} +private object Factor { + const val MILLIS_TO_SECONDS = 1000.0 + const val SECONDS_TO_MINUTES = 60.0 + const val MINUTES_TO_HOURS = 60.0 + const val HOURS_TO_DAYS = 24.0 + const val DAYS_TO_WEEKS = 7.0 + const val DAYS_TO_MONTHS = 30.0 + const val DAYS_TO_YEARS = 365.0 +} + +private object Threshold { + const val SECONDS = 60 + const val MINUTES = 60 + const val HOURS = 24 + const val YESTERDAY = 2 + const val DAYS = 7 + const val WEEKS = 4 + const val MONTHS = 12 +} + +object CalendarConstants { // Calendar grid const val DAYS_IN_WEEK = 7 - const val FIRST_DAY_OF_MONTH = 1 // Date formatting const val WEEKDAY_ABBREVIATION_LENGTH = 3 @@ -160,12 +227,4 @@ object CalendarConstants { const val DAYS_IN_WEEK_MOD = 7 const val CALENDAR_WEEK_OFFSET = 1 const val MONTH_INDEX_OFFSET = 1 - - // Leap year calculation - const val LEAP_YEAR_DIVISOR_4 = 4 - const val LEAP_YEAR_DIVISOR_100 = 100 - const val LEAP_YEAR_DIVISOR_400 = 400 - - // Preview - const val PREVIEW_DAYS_AGO = 7 } diff --git a/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt b/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt new file mode 100644 index 000000000..b586d9d8d --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/TagMetadataEntity.kt @@ -0,0 +1,31 @@ +package to.bitkit.ext + +import com.synonym.bitkitcore.PreActivityMetadata +import to.bitkit.data.entities.TagMetadataEntity + +// TODO use PreActivityMetadata +fun TagMetadataEntity.toActivityTagsMetadata() = PreActivityMetadata( + paymentId = id, + createdAt = createdAt.toULong(), + tags = tags, + paymentHash = paymentHash, + txId = txId, + address = address, + isReceive = isReceive, + feeRate = 0u, // TODO: update room db entity or drop it in favour of bitkit-core + isTransfer = false, // TODO: update room db entity or drop it in favour of bitkit-core + channelId = "", // TODO: update room db entity or drop it in favour of bitkit-core +) + +fun PreActivityMetadata.toTagMetadataEntity() = TagMetadataEntity( + id = paymentId, + createdAt = createdAt.toLong(), + tags = tags, + paymentHash = paymentHash, + txId = txId, + address = address.orEmpty(), + isReceive = isReceive, + // feeRate = 0u, + // isTransfer = false, + // channelId = "", +) diff --git a/app/src/main/java/to/bitkit/models/BackupCategory.kt b/app/src/main/java/to/bitkit/models/BackupCategory.kt index 519d85964..0181b711e 100644 --- a/app/src/main/java/to/bitkit/models/BackupCategory.kt +++ b/app/src/main/java/to/bitkit/models/BackupCategory.kt @@ -50,9 +50,9 @@ enum class BackupCategory( } /** - * @property running In progress - * @property synced Timestamp in ms of last time this backup was synced - * @property required Timestamp in ms of last time this backup was required + * @property running Backup is currently in progress + * @property synced Timestamp in millis of last time this backup succeeded + * @property required Timestamp in millis of last time the data changed */ @Serializable data class BackupItemStatus( diff --git a/app/src/main/java/to/bitkit/models/BackupPayloads.kt b/app/src/main/java/to/bitkit/models/BackupPayloads.kt index 16b48d9c4..ab0a8980f 100644 --- a/app/src/main/java/to/bitkit/models/BackupPayloads.kt +++ b/app/src/main/java/to/bitkit/models/BackupPayloads.kt @@ -1,13 +1,16 @@ package to.bitkit.models import com.synonym.bitkitcore.Activity +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IBtInfo import com.synonym.bitkitcore.IBtOrder import com.synonym.bitkitcore.IcJitEntry +import com.synonym.bitkitcore.PreActivityMetadata import kotlinx.serialization.Serializable import to.bitkit.data.AppCacheData -import to.bitkit.data.entities.TagMetadataEntity +import to.bitkit.data.SettingsData +import to.bitkit.data.WidgetsData import to.bitkit.data.entities.TransferEntity @Serializable @@ -21,7 +24,7 @@ data class WalletBackupV1( data class MetadataBackupV1( val version: Int = 1, val createdAt: Long, - val tagMetadata: List, + val tagMetadata: List, val cache: AppCacheData, ) @@ -39,5 +42,20 @@ data class ActivityBackupV1( val version: Int = 1, val createdAt: Long, val activities: List, + val activityTags: List, val closedChannels: List, ) + +@Serializable +data class SettingsBackupV1( + val version: Int = 1, + val createdAt: Long, + val settings: SettingsData, +) + +@Serializable +data class WidgetsBackupV1( + val version: Int = 1, + val createdAt: Long, + val widgets: WidgetsData, +) diff --git a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt index 0d476281c..62dd15156 100644 --- a/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/ActivityRepo.kt @@ -1,12 +1,15 @@ package to.bitkit.repositories +import androidx.annotation.VisibleForTesting import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.ClosedChannelDetails import com.synonym.bitkitcore.IcJitEntry import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.SortDirection import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.TimeoutCancellationException @@ -28,6 +31,7 @@ import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.di.BgDispatcher import to.bitkit.ext.amountOnClose import to.bitkit.ext.matchesPaymentId +import to.bitkit.ext.nowMillis import to.bitkit.ext.nowTimestamp import to.bitkit.ext.rawId import to.bitkit.models.ActivityBackupV1 @@ -53,53 +57,56 @@ class ActivityRepo @Inject constructor( ) { val isSyncingLdkNodePayments = MutableStateFlow(false) + private val _state = MutableStateFlow(ActivityState()) + val state: StateFlow = _state + private val _activitiesChanged = MutableStateFlow(0L) val activitiesChanged: StateFlow = _activitiesChanged - private fun notifyActivitiesChanged() = _activitiesChanged.update { clock.now().toEpochMilliseconds() } + private fun notifyActivitiesChanged() = _activitiesChanged.update { nowMillis(clock) } + + suspend fun resetState() = withContext(bgDispatcher) { + _state.update { ActivityState() } + isSyncingLdkNodePayments.update { false } + notifyActivitiesChanged() + Logger.debug("Activity state reset", context = TAG) + } suspend fun syncActivities(): Result = withContext(bgDispatcher) { Logger.debug("syncActivities called", context = TAG) - return@withContext runCatching { + val result = runCatching { withTimeout(SYNC_TIMEOUT_MS) { Logger.debug("isSyncingLdkNodePayments = ${isSyncingLdkNodePayments.value}", context = TAG) isSyncingLdkNodePayments.first { !it } } - isSyncingLdkNodePayments.value = true + isSyncingLdkNodePayments.update { true } deletePendingActivities() - return@withContext lightningRepo.getPayments() - .onSuccess { payments -> - Logger.debug("Got payments with success, syncing activities", context = TAG) - syncLdkNodePayments(payments = payments).onFailure { e -> - return@withContext Result.failure(e) - } - updateActivitiesMetadata() - syncTagsMetadata() - boostPendingActivities() - transferRepo.syncTransferStates() - isSyncingLdkNodePayments.value = false - return@withContext Result.success(Unit) - }.onFailure { e -> - Logger.error("Failed to sync ldk-node payments", e, context = TAG) - isSyncingLdkNodePayments.value = false - return@withContext Result.failure(e) - }.map { Unit } - }.onFailure { e -> - when (e) { - is TimeoutCancellationException -> { - isSyncingLdkNodePayments.value = false - Logger.warn("Timeout waiting for sync to complete, forcing reset", context = TAG) - } - else -> { - isSyncingLdkNodePayments.value = false - Logger.error("syncActivities error", e, context = TAG) - } + lightningRepo.getPayments().mapCatching { payments -> + Logger.debug("Got payments with success, syncing activities", context = TAG) + syncLdkNodePayments(payments).getOrThrow() + updateActivitiesMetadata() + syncTagsMetadata() + boostPendingActivities() + transferRepo.syncTransferStates().getOrThrow() + }.onSuccess { + getAllAvailableTags().getOrNull() + }.getOrThrow() + }.onFailure { e -> + if (e is TimeoutCancellationException) { + Logger.warn("syncActivities timeout, forcing reset", context = TAG) + } else { + Logger.error("Failed to sync activities", e, context = TAG) } } + + isSyncingLdkNodePayments.update { false } + notifyActivitiesChanged() + + return@withContext result } /** @@ -326,10 +333,11 @@ class ActivityRepo @Inject constructor( }.awaitAll() } - private suspend fun syncTagsMetadata() = withContext(context = bgDispatcher) { + @Suppress("LongMethod") + private suspend fun syncTagsMetadata(): Result = withContext(context = bgDispatcher) { runCatching { - if (db.tagMetadataDao().getAll().isEmpty()) return@withContext - val lastActivities = getActivities(limit = 10u).getOrNull() ?: return@withContext + if (db.tagMetadataDao().getAll().isEmpty()) return@runCatching + val lastActivities = getActivities(limit = 10u).getOrNull() ?: return@runCatching Logger.debug("syncTagsMetadata called") lastActivities.map { activity -> @@ -405,6 +413,7 @@ class ActivityRepo @Inject constructor( } } }.awaitAll() + Result.success(Unit) } } @@ -541,10 +550,11 @@ class ActivityRepo @Inject constructor( cacheStore.addActivityToPendingBoost(pendingBoostActivity) } - /** - * Adds tags to an activity with business logic validation - */ - suspend fun addTagsToActivity(activityId: String, tags: List): Result = withContext(bgDispatcher) { + @VisibleForTesting + suspend fun addTagsToActivity( + activityId: String, + tags: List, + ): Result = withContext(bgDispatcher) { return@withContext runCatching { checkNotNull(coreService.activity.getActivity(activityId)) { "Activity with ID $activityId not found" } @@ -577,11 +587,9 @@ class ActivityRepo @Inject constructor( paymentHashOrTxId = paymentHashOrTxId, type = type, txType = txType - ).onSuccess { activity -> - addTagsToActivity(activity.rawId(), tags = tags) - }.onFailure { e -> - return@withContext Result.failure(e) - }.map { Unit } + ).mapCatching { activity -> + addTagsToActivity(activity.rawId(), tags = tags).getOrThrow() + } } /** @@ -611,17 +619,49 @@ class ActivityRepo @Inject constructor( } } - /** - * Gets all possible tags across all activities - */ suspend fun getAllAvailableTags(): Result> = withContext(bgDispatcher) { return@withContext runCatching { coreService.activity.allPossibleTags() + }.onSuccess { tags -> + _state.update { it.copy(tags = tags) } }.onFailure { e -> Logger.error("getAllAvailableTags error", e, context = TAG) } } + /** + * Get all [ActivityTags] for backup + */ + suspend fun getAllActivitiesTags(): Result> = withContext(bgDispatcher) { + return@withContext runCatching { + coreService.activity.getAllActivitiesTags() + }.onFailure { e -> + Logger.error("getAllActivityTags error", e, context = TAG) + } + } + + /** + * Get all [PreActivityMetadata] for backup + */ + suspend fun getAllPreActivityMetadata(): Result> = withContext(bgDispatcher) { + return@withContext runCatching { + coreService.activity.getAllPreActivityMetadata() + }.onFailure { e -> + Logger.error("getAllPreActivityMetadata error", e, context = TAG) + } + } + + /** + * Upsert all [PreActivityMetadata] + */ + suspend fun upsertPreActivityMetadata(list: List): Result = withContext(bgDispatcher) { + return@withContext runCatching { + coreService.activity.upsertPreActivityMetadata(list) + }.onFailure { e -> + Logger.error("upsertPreActivityMetadata error", e, context = TAG) + } + } + suspend fun saveTagsMetadata( id: String, paymentHash: String? = null, @@ -649,10 +689,18 @@ class ActivityRepo @Inject constructor( } } - suspend fun restoreFromBackup(backup: ActivityBackupV1): Result = withContext(bgDispatcher) { + suspend fun restoreFromBackup(payload: ActivityBackupV1): Result = withContext(bgDispatcher) { return@withContext runCatching { - coreService.activity.upsertList(backup.activities) - coreService.activity.upsertClosedChannelList(backup.closedChannels) + coreService.activity.upsertList(payload.activities) + coreService.activity.upsertTags(payload.activityTags) + coreService.activity.upsertClosedChannelList(payload.closedChannels) + }.onSuccess { + Logger.debug( + "Restored ${payload.activities.size} activities, ${payload.activityTags.size} activity tags, " + + "${payload.closedChannels.size} closed channels", + context = TAG, + ) + notifyActivitiesChanged() } } @@ -692,3 +740,7 @@ class ActivityRepo @Inject constructor( private const val TAG = "ActivityRepo" } } + +data class ActivityState( + val tags: List = emptyList(), +) diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 0af0fd79f..142a0f4b2 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,15 +16,14 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import to.bitkit.R import to.bitkit.data.AppDb import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.resetPin @@ -31,25 +31,44 @@ import to.bitkit.di.IoDispatcher import to.bitkit.di.json import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis +import to.bitkit.ext.toActivityTagsMetadata +import to.bitkit.ext.toTagMetadataEntity import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 +import to.bitkit.models.SettingsBackupV1 import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 +import to.bitkit.models.WidgetsBackupV1 import to.bitkit.services.LightningService import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +/** + * Manages backup & restore of wallet metadata to a remote VSS server. + * + * **Backup State Machine:** + * ``` + * Idle State: running=false, synced≥required + * ↓ (data changes → markBackupRequired()) + * Pending State: running=false, synced() private val dataListenerJobs = mutableListOf() private var periodicCheckJob: Job? = null + + private val runningBackups = ConcurrentHashMap.newKeySet() // Tracks active jobs since app start + private var isObserving = false + private var lastNotificationTime = 0L + private val _isRestoring = MutableStateFlow(false) val isRestoring: StateFlow = _isRestoring.asStateFlow() - private var lastNotificationTime = 0L + private val _isWiping = MutableStateFlow(false) fun reset() { stopObservingBackups() vssBackupClient.reset() } + fun setWiping(isWiping: Boolean) = _isWiping.update { isWiping } + private fun currentTimeMillis(): Long = nowMillis(clock) + private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value + private fun BackupItemStatus.shouldBackup() = this.isRequired && !this.running && !shouldSkipBackup() + fun startObservingBackups() { if (isObserving) return @@ -84,6 +113,22 @@ class BackupRepo @Inject constructor( Logger.debug("Start observing backup statuses and data store changes", context = TAG) scope.launch { vssBackupClient.setup() } + + scope.launch { + BackupCategory.entries.forEach { category -> + if (category !in runningBackups) { + cacheStore.updateBackupStatus(category) { status -> + if (status.running) { + Logger.debug("Clearing stale running flag for: '$category'", context = TAG) + status.copy(running = false) + } else { + status + } + } + } + } + } + startBackupStatusObservers() startDataStoreListeners() startPeriodicBackupFailureCheck() @@ -124,7 +169,7 @@ class BackupRepo @Inject constructor( old.synced == new.synced && old.required == new.required } .collect { status -> - if (status.isRequired && !status.running && !isRestoring.value) { + if (status.shouldBackup()) { scheduleBackup(category) } } @@ -142,7 +187,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.SETTINGS) } } @@ -153,7 +198,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.WIDGETS) } } @@ -165,7 +210,7 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.WALLET) } } @@ -177,20 +222,21 @@ class BackupRepo @Inject constructor( .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.METADATA) } } dataListenerJobs.add(tagMetadataJob) // METADATA - Observe entire CacheStore excluding backup statuses + // TODO use PreActivityMetadata val cacheMetadataJob = scope.launch { cacheStore.data .map { it.copy(backupStatuses = mapOf()) } .distinctUntilChanged() .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.METADATA) } } @@ -201,18 +247,18 @@ class BackupRepo @Inject constructor( blocktankRepo.blocktankState .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.BLOCKTANK) } } dataListenerJobs.add(blocktankJob) - // ACTIVITY - Observe all activity changes notified by ActivityRepo on any mutation to core's activity store + // ACTIVITY - Observe activity changes val activityChangesJob = scope.launch { activityRepo.activitiesChanged .drop(1) .collect { - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect markBackupRequired(BackupCategory.ACTIVITY) } } @@ -225,7 +271,7 @@ class BackupRepo @Inject constructor( val lastSync = lightningService.status?.latestLightningWalletSyncTimestamp?.toLong() ?.let { it * 1000 } // Convert seconds to millis ?: return@collect - if (isRestoring.value) return@collect + if (shouldSkipBackup()) return@collect cacheStore.updateBackupStatus(BackupCategory.LIGHTNING_CONNECTIONS) { it.copy(required = lastSync, synced = lastSync, running = false) } @@ -238,7 +284,7 @@ class BackupRepo @Inject constructor( private fun startPeriodicBackupFailureCheck() { periodicCheckJob = scope.launch { - while (true) { + while (currentCoroutineContext().isActive) { delay(BACKUP_CHECK_INTERVAL) checkForFailedBackups() } @@ -255,29 +301,40 @@ class BackupRepo @Inject constructor( } private fun scheduleBackup(category: BackupCategory) { - // Cancel existing backup job for this category backupJobs[category]?.cancel() Logger.verbose("Scheduling backup for: '$category'", context = TAG) backupJobs[category] = scope.launch { - // Set running immediately to prevent UI showing failure during debounce + runningBackups += category cacheStore.updateBackupStatus(category) { it.copy(running = true) } delay(BACKUP_DEBOUNCE) - // Double-check if backup is still needed val status = cacheStore.backupStatuses.first()[category] ?: BackupItemStatus() - if (status.isRequired && !isRestoring.value) { + if (status.isRequired && !shouldSkipBackup()) { triggerBackup(category) } else { - // Backup no longer needed, reset running flag + Logger.debug("Backup no longer needed for: '$category'", context = TAG) + runningBackups -= category cacheStore.updateBackupStatus(category) { it.copy(running = false) } } + }.also { job -> + job.invokeOnCompletion { exception -> + if (exception != null) { + Logger.debug("Backup job cancelled for: '$category'", context = TAG) + scope.launch { + runningBackups -= category + cacheStore.updateBackupStatus(category) { + it.copy(running = false) + } + } + } + } } } @@ -321,12 +378,14 @@ class BackupRepo @Inject constructor( suspend fun triggerBackup(category: BackupCategory) = withContext(ioDispatcher) { Logger.debug("Backup starting for: '$category'", context = TAG) + runningBackups += category cacheStore.updateBackupStatus(category) { it.copy(running = true, required = currentTimeMillis()) } vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)) .onSuccess { + runningBackups -= category cacheStore.updateBackupStatus(category) { it.copy( running = false, @@ -336,6 +395,7 @@ class BackupRepo @Inject constructor( Logger.info("Backup succeeded for: '$category'", context = TAG) } .onFailure { e -> + runningBackups -= category cacheStore.updateBackupStatus(category) { it.copy(running = false) } @@ -346,12 +406,20 @@ class BackupRepo @Inject constructor( private suspend fun getBackupDataBytes(category: BackupCategory): ByteArray = when (category) { BackupCategory.SETTINGS -> { val data = settingsStore.data.first().resetPin() - json.encodeToString(data).toByteArray() + val payload = SettingsBackupV1( + createdAt = currentTimeMillis(), + settings = data, + ) + json.encodeToString(payload).toByteArray() } BackupCategory.WIDGETS -> { val data = widgetsStore.data.first() - json.encodeToString(data).toByteArray() + val payload = WidgetsBackupV1( + createdAt = currentTimeMillis(), + widgets = data, + ) + json.encodeToString(payload).toByteArray() } BackupCategory.WALLET -> { @@ -366,8 +434,10 @@ class BackupRepo @Inject constructor( } BackupCategory.METADATA -> { - val tagMetadata = db.tagMetadataDao().getAll() + val tagMetadata = db.tagMetadataDao().getAll().map { it.toActivityTagsMetadata() } val cacheData = cacheStore.data.first() + // TODO use PreActivityMetadata + // val preActivityMetadata = activityRepo.getAllPreActivityMetadata().getOrDefault(emptyList()) val payload = MetadataBackupV1( createdAt = currentTimeMillis(), @@ -394,10 +464,12 @@ class BackupRepo @Inject constructor( BackupCategory.ACTIVITY -> { val activities = activityRepo.getActivities().getOrDefault(emptyList()) val closedChannels = activityRepo.getClosedChannels().getOrDefault(emptyList()) + val activityTags = activityRepo.getAllActivitiesTags().getOrDefault(emptyList()) val payload = ActivityBackupV1( createdAt = currentTimeMillis(), activities = activities, + activityTags = activityTags, closedChannels = closedChannels, ) @@ -417,41 +489,43 @@ class BackupRepo @Inject constructor( return@withContext try { performRestore(BackupCategory.METADATA) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - val caches = parsed.cache.copy(onchainAddress = "") // Force onchain address rotation - cacheStore.update { caches } + val cleanedUp = parsed.cache.copy(onchainAddress = "") // Force address rotation + cacheStore.update { cleanedUp } Logger.debug("Restored caches: ${jsonLogOf(parsed.cache.copy(cachedRates = emptyList()))}", TAG) onCacheRestored() - db.tagMetadataDao().upsert(parsed.tagMetadata) - Logger.debug("Restored caches and ${parsed.tagMetadata.size} tags metadata records", TAG) + // TODO use PreActivityMetadata + // activityRepo.upsertPreActivityMetadata(parsed.tagMetadata) + val tagMetadata = parsed.tagMetadata.map { it.toTagMetadataEntity() } + db.tagMetadataDao().upsert(tagMetadata) + Logger.debug("Restored ${tagMetadata.size} pre-activity metadata", TAG) + parsed.createdAt } performRestore(BackupCategory.SETTINGS) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)).resetPin() - settingsStore.update { parsed } + val parsed = json.decodeFromString(String(dataBytes)) + settingsStore.restoreFromBackup(parsed) + parsed.createdAt } performRestore(BackupCategory.WIDGETS) { dataBytes -> - val parsed = json.decodeFromString(String(dataBytes)) - widgetsStore.update { parsed } + val parsed = json.decodeFromString(String(dataBytes)) + widgetsStore.restoreFromBackup(parsed) + parsed.createdAt } performRestore(BackupCategory.WALLET) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) db.transferDao().upsert(parsed.transfers) Logger.debug("Restored ${parsed.transfers.size} transfers", context = TAG) + parsed.createdAt } performRestore(BackupCategory.BLOCKTANK) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - blocktankRepo.restoreFromBackup(parsed).onSuccess { - Logger.debug("Restored ${parsed.orders.size} orders, ${parsed.cjitEntries.size} CJITs", TAG) - } + blocktankRepo.restoreFromBackup(parsed) + parsed.createdAt } performRestore(BackupCategory.ACTIVITY) { dataBytes -> val parsed = json.decodeFromString(String(dataBytes)) - activityRepo.restoreFromBackup(parsed).onSuccess { - Logger.debug( - "Restored ${parsed.activities.size} activities, ${parsed.closedChannels.size} closed channels", - context = TAG, - ) - } + activityRepo.restoreFromBackup(parsed) + parsed.createdAt } Logger.info("Full restore success", context = TAG) @@ -475,14 +549,16 @@ class BackupRepo @Inject constructor( private suspend fun performRestore( category: BackupCategory, - restoreAction: suspend (ByteArray) -> Unit, + restoreAction: suspend (dataBytes: ByteArray) -> Long, ): Result = runCatching { + var createdAtTimestamp = currentTimeMillis() + vssBackupClient.getObject(category.name).map { it?.value } .onSuccess { dataBytes -> if (dataBytes == null) { Logger.warn("Restore null for: '$category'", context = TAG) } else { - restoreAction(dataBytes) + createdAtTimestamp = restoreAction(dataBytes) Logger.info("Restore success for: '$category'", context = TAG) } } @@ -490,14 +566,11 @@ class BackupRepo @Inject constructor( Logger.debug("Restore error for: '$category'", context = TAG) } - val now = currentTimeMillis() cacheStore.updateBackupStatus(category) { - it.copy(running = false, synced = now, required = now) + it.copy(running = false, synced = createdAtTimestamp, required = createdAtTimestamp) } } - private fun currentTimeMillis(): Long = nowMillis(clock) - companion object { private const val TAG = "BackupRepo" diff --git a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt index 2a1388aae..49a5326d9 100644 --- a/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BlocktankRepo.kt @@ -373,13 +373,14 @@ class BlocktankRepo @Inject constructor( suspend fun resetState() = withContext(bgDispatcher) { _blocktankState.update { BlocktankState() } + Logger.debug("Blocktank state reset", context = TAG) } - suspend fun restoreFromBackup(backup: BlocktankBackupV1): Result = withContext(bgDispatcher) { + suspend fun restoreFromBackup(payload: BlocktankBackupV1): Result = withContext(bgDispatcher) { return@withContext runCatching { - coreService.blocktank.upsertOrderList(backup.orders) - coreService.blocktank.upsertCjitList(backup.cjitEntries) - backup.info?.let { info -> + coreService.blocktank.upsertOrderList(payload.orders) + coreService.blocktank.upsertCjitList(payload.cjitEntries) + payload.info?.let { info -> coreService.blocktank.setInfo(info) } @@ -388,11 +389,13 @@ class BlocktankRepo @Inject constructor( _blocktankState.update { it.copy( - orders = backup.orders, - cjitEntries = backup.cjitEntries, - info = backup.info, + orders = payload.orders, + cjitEntries = payload.cjitEntries, + info = payload.info, ) } + }.onSuccess { + Logger.debug("Restored ${payload.orders.size} orders, ${payload.cjitEntries.size} CJITs", TAG) } } diff --git a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt index a2f735471..5ac0a51da 100644 --- a/app/src/main/java/to/bitkit/repositories/LogsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/LogsRepo.kt @@ -58,8 +58,7 @@ class LogsRepo @Inject constructor( /** Lists log files sorted by newest first */ suspend fun getLogs(): Result> = withContext(bgDispatcher) { try { - val logDir = runCatching { File(Env.logDir) }.getOrElse { return@withContext Result.failure(it) } - if (!logDir.exists()) return@withContext Result.failure(Exception("Logs dir not found")) + val logDir = Env.logDir val logFiles = logDir .listFiles { file -> file.extension == "log" } diff --git a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt index b8fb5db3b..9157ffb68 100644 --- a/app/src/main/java/to/bitkit/repositories/WalletRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WalletRepo.kt @@ -18,7 +18,6 @@ import org.lightningdevkit.ldknode.Event import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore -import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.entities.TagMetadataEntity import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher @@ -31,6 +30,7 @@ import to.bitkit.models.BalanceState import to.bitkit.models.toDerivationPath import to.bitkit.services.CoreService import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.AddressChecker import to.bitkit.utils.Bip21Utils import to.bitkit.utils.Logger @@ -51,8 +51,7 @@ class WalletRepo @Inject constructor( private val lightningRepo: LightningRepo, private val cacheStore: CacheStore, private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase, - private val vssStoreIdProvider: VssStoreIdProvider, - private val backupRepo: BackupRepo, + private val wipeWalletUseCase: WipeWalletUseCase, ) { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) @@ -238,25 +237,16 @@ class WalletRepo @Inject constructor( } suspend fun wipeWallet(walletIndex: Int = 0): Result = withContext(bgDispatcher) { - try { - backupRepo.reset() - - _walletState.update { WalletState() } - _balanceState.update { BalanceState() } - - keychain.wipe() - db.clearAllTables() - settingsStore.reset() - cacheStore.reset() - // TODO CLEAN ACTIVITY'S AND UPDATE STATE. CHECK ActivityListViewModel.removeAllActivities - coreService.activity.removeAll() - setWalletExistsState() + return@withContext wipeWalletUseCase( + walletIndex = walletIndex, + resetWalletState = ::resetState, + onSuccess = ::setWalletExistsState, + ) + } - return@withContext lightningRepo.wipeStorage(walletIndex = walletIndex) - } catch (e: Throwable) { - Logger.error("Wipe wallet error", e) - Result.failure(e) - } + fun resetState() { + _walletState.update { WalletState() } + _balanceState.update { BalanceState() } } // Blockchain address management diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 62c7c7153..e638bdae5 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -2,6 +2,7 @@ package to.bitkit.services import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.ActivityFilter +import com.synonym.bitkitcore.ActivityTags import com.synonym.bitkitcore.BtOrderState2 import com.synonym.bitkitcore.CJitStateEnum import com.synonym.bitkitcore.ClosedChannelDetails @@ -18,6 +19,7 @@ import com.synonym.bitkitcore.LightningActivity import com.synonym.bitkitcore.OnchainActivity import com.synonym.bitkitcore.PaymentState import com.synonym.bitkitcore.PaymentType +import com.synonym.bitkitcore.PreActivityMetadata import com.synonym.bitkitcore.SortDirection import com.synonym.bitkitcore.WordCount import com.synonym.bitkitcore.addTags @@ -44,10 +46,10 @@ import com.synonym.bitkitcore.updateBlocktankUrl import com.synonym.bitkitcore.upsertActivities import com.synonym.bitkitcore.upsertActivity import com.synonym.bitkitcore.upsertCjitEntries -import com.synonym.bitkitcore.upsertClosedChannel import com.synonym.bitkitcore.upsertClosedChannels import com.synonym.bitkitcore.upsertInfo import com.synonym.bitkitcore.upsertOrders +import com.synonym.bitkitcore.wipeAllDatabases import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.http.HttpStatusCode @@ -167,6 +169,19 @@ class CoreService @Inject constructor( return Pair(geoBlocked, shouldBlockLightningReceive) } + + suspend fun wipeData(): Result = ServiceQueue.CORE.background { + runCatching { + val result = wipeAllDatabases() + Logger.info("Core DB wipe: $result", context = TAG) + }.onFailure { e -> + Logger.error("Core DB wipe error", e, context = TAG) + } + } + + companion object { + private const val TAG = "CoreService" + } } // endregion @@ -175,7 +190,7 @@ class CoreService @Inject constructor( private const val CHUNK_SIZE = 50 class ActivityService( - private val coreService: CoreService, + @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val cacheStore: CacheStore, ) { suspend fun removeAll() { @@ -215,14 +230,6 @@ class ActivityService( upsertActivities(activities) } - suspend fun upsertClosedChannelItem(closedChannel: ClosedChannelDetails) = ServiceQueue.CORE.background { - upsertClosedChannel(closedChannel) - } - - suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { - upsertClosedChannels(closedChannels) - } - suspend fun getActivity(id: String): Activity? { return ServiceQueue.CORE.background { getActivityById(id) @@ -285,6 +292,26 @@ class ActivityService( } } + suspend fun upsertTags(activityTags: List) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.upsertTags(activityTags) + } + + suspend fun getAllActivitiesTags(): List = ServiceQueue.CORE.background { + com.synonym.bitkitcore.getAllActivitiesTags() + } + + suspend fun getAllPreActivityMetadata(): List = ServiceQueue.CORE.background { + com.synonym.bitkitcore.getAllPreActivityMetadata() + } + + suspend fun upsertPreActivityMetadata(list: List) = ServiceQueue.CORE.background { + com.synonym.bitkitcore.upsertPreActivityMetadata(list) + } + + suspend fun upsertClosedChannelList(closedChannels: List) = ServiceQueue.CORE.background { + upsertClosedChannels(closedChannels) + } + suspend fun closedChannels( sortDirection: SortDirection, ): List = ServiceQueue.CORE.background { @@ -576,7 +603,7 @@ class ActivityService( // region Blocktank class BlocktankService( - private val coreService: CoreService, + @Suppress("unused") private val coreService: CoreService, // used to ensure CoreService inits first private val lightningService: LightningService, ) { suspend fun info(refresh: Boolean = true): IBtInfo? { diff --git a/app/src/main/java/to/bitkit/services/core/Bip39Service.kt b/app/src/main/java/to/bitkit/services/core/Bip39Service.kt new file mode 100644 index 000000000..4541fbf53 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/core/Bip39Service.kt @@ -0,0 +1,30 @@ +package to.bitkit.services.core + +import com.synonym.bitkitcore.getBip39Suggestions +import com.synonym.bitkitcore.isValidBip39Word +import to.bitkit.async.ServiceQueue +import javax.inject.Inject + +class Bip39Service @Inject constructor() { + suspend fun getSuggestions(input: String, count: UInt): List = ServiceQueue.CORE.background { + getBip39Suggestions(input, count) + } + + suspend fun isValidWord(word: String): Boolean = ServiceQueue.CORE.background { + isValidBip39Word(word) + } + + suspend fun validateMnemonic(mnemonic: String): Result = ServiceQueue.CORE.background { + runCatching { com.synonym.bitkitcore.validateMnemonic(mnemonic) } + } + + fun isValidMnemonicSize(wordList: List): Boolean = MnemonicSize.isValid(wordList) + + private enum class MnemonicSize(val wordCount: Int) { + TWELVE(12), TWENTY_FOUR(24); + + companion object { + fun isValid(wordList: List): Boolean = entries.any { it.wordCount == wordList.size } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c4979c18d..f7d234cc6 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -311,7 +311,7 @@ fun ContentView( LaunchedEffect(balance) { // Anytime we receive a balance update, we should sync the payments to activity list - activityListViewModel.syncLdkNodePayments() + activityListViewModel.resync() } // Keep backups in sync diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index 6b2b389ec..cab8868cf 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -36,7 +36,7 @@ import to.bitkit.ui.components.ToastOverlay import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen import to.bitkit.ui.onboarding.IntroScreen import to.bitkit.ui.onboarding.OnboardingSlidesScreen -import to.bitkit.ui.onboarding.RestoreWalletView +import to.bitkit.ui.onboarding.RestoreWalletScreen import to.bitkit.ui.onboarding.TermsOfUseScreen import to.bitkit.ui.onboarding.WarningMultipleDevicesScreen import to.bitkit.ui.screens.SplashScreen @@ -151,7 +151,8 @@ class MainActivity : FragmentActivity() { } ) - if (appViewModel.showNewTransaction) { + val showNewTransaction by appViewModel.showNewTransaction.collectAsStateWithLifecycle() + if (showNewTransaction) { NewTransactionSheet( appViewModel = appViewModel, currencyViewModel = currencyViewModel, @@ -235,14 +236,12 @@ private fun OnboardingNav( ) } composableWithDefaultTransitions { - RestoreWalletView( + RestoreWalletScreen( onBackClick = { startupNavController.popBackStack() }, onRestoreClick = { mnemonic, passphrase -> scope.launch { runCatching { appViewModel.resetIsAuthenticatedState() - walletViewModel.setInitNodeLifecycleState() - walletViewModel.setRestoringWalletState() walletViewModel.restoreWallet(mnemonic, passphrase) }.onFailure { appViewModel.toast(it) @@ -258,7 +257,6 @@ private fun OnboardingNav( scope.launch { runCatching { appViewModel.resetIsAuthenticatedState() - walletViewModel.setInitNodeLifecycleState() walletViewModel.createWallet(bip39Passphrase = passphrase) }.onFailure { appViewModel.toast(it) diff --git a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt index 47d5d98bb..8ac9fd1b5 100644 --- a/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt +++ b/app/src/main/java/to/bitkit/ui/components/MnemonicWordsGrid.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.utils.bip39Words @Composable fun MnemonicWordsGrid( @@ -98,12 +97,14 @@ private fun WordItem( } } +private val previewWords = List(8) { "word${it + 1}" } + @Preview @Composable private fun Preview() { AppThemeSurface { MnemonicWordsGrid( - actualWords = bip39Words.take(n = 12), + actualWords = previewWords, showMnemonic = true, ) } @@ -114,7 +115,7 @@ private fun Preview() { private fun PreviewHidden() { AppThemeSurface { MnemonicWordsGrid( - actualWords = bip39Words.take(n = 12), + actualWords = previewWords, showMnemonic = false, ) } diff --git a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt index 3360aa486..b30b5183c 100644 --- a/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/onboarding/RestoreWalletScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -24,27 +25,36 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.coroutines.launch +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS @@ -52,64 +62,90 @@ import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TextInput +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.theme.AppTextFieldDefaults +import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent -import to.bitkit.utils.bip39Words -import to.bitkit.utils.isBip39 -import to.bitkit.utils.validBip39Checksum +import to.bitkit.viewmodels.RestoreWalletUiState +import to.bitkit.viewmodels.RestoreWalletViewModel @Composable -fun RestoreWalletView( +fun RestoreWalletScreen( onBackClick: () -> Unit, onRestoreClick: (mnemonic: String, passphrase: String?) -> Unit, + modifier: Modifier = Modifier, + viewModel: RestoreWalletViewModel = hiltViewModel(), ) { - val words = remember { mutableStateListOf(*Array(24) { "" }) } - val invalidWordIndices = remember { mutableStateListOf() } - val suggestions = remember { mutableStateListOf() } - var focusedIndex by remember { mutableStateOf(null) } - var bip39Passphrase by remember { mutableStateOf("") } - var showingPassphrase by remember { mutableStateOf(false) } - var firstFieldText by remember { mutableStateOf("") } - var is24Words by remember { mutableStateOf(false) } - val checksumErrorVisible by remember { - derivedStateOf { - val wordCount = if (is24Words) 24 else 12 - words.subList(0, wordCount).none { it.isBlank() } && invalidWordIndices.isEmpty() && !words.subList( - 0, - wordCount - ).validBip39Checksum() - } - } + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + Content( + uiState = uiState, + checksumErrorVisible = uiState.checksumErrorVisible, + areButtonsEnabled = uiState.areButtonsEnabled, + onChangeWord = viewModel::onChangeWord, + onChangeWordFocus = viewModel::onChangeWordFocus, + onChangePassphrase = viewModel::onChangePassphrase, + onBackspaceInEmpty = viewModel::onBackspaceInEmpty, + onSelectSuggestion = viewModel::onSelectSuggestion, + onKeyboardDismiss = viewModel::onKeyboardDismiss, + onScrollComplete = viewModel::onScrollComplete, + onAdvancedClick = viewModel::onAdvancedClick, + onBack = onBackClick, + onRestore = onRestoreClick, + modifier = modifier, + ) +} +@Composable +private fun Content( + uiState: RestoreWalletUiState, + checksumErrorVisible: Boolean, + areButtonsEnabled: Boolean, + modifier: Modifier = Modifier, + onChangeWord: (Int, String) -> Unit = { _, _ -> }, + onChangeWordFocus: (Int, Boolean) -> Unit = { _, _ -> }, + onChangePassphrase: (String) -> Unit = {}, + onBackspaceInEmpty: (Int) -> Unit = {}, + onSelectSuggestion: (String) -> Unit = {}, + onKeyboardDismiss: () -> Unit = {}, + onScrollComplete: () -> Unit = {}, + onAdvancedClick: () -> Unit = {}, + onRestore: (mnemonic: String, passphrase: String?) -> Unit = { _, _ -> }, + onBack: () -> Unit = {}, +) { val scrollState = rememberScrollState() - val coroutineScope = rememberCoroutineScope() val inputFieldPositions = remember { mutableMapOf() } + val focusRequesters = remember(uiState.wordCount) { List(uiState.wordCount) { FocusRequester() } } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current - val wordsPerColumn = if (is24Words) 12 else 6 + val currentOnKeyboardDismiss by rememberUpdatedState(onKeyboardDismiss) + val currentOnScrollComplete by rememberUpdatedState(onScrollComplete) - val bip39Mnemonic by remember { - derivedStateOf { - val wordCount = if (is24Words) 24 else 12 - words.subList(0, wordCount) - .joinToString(separator = " ") - .trim() + LaunchedEffect(uiState.shouldDismissKeyboard) { + if (uiState.shouldDismissKeyboard) { + focusManager.clearFocus() + keyboardController?.hide() + currentOnKeyboardDismiss() } } - fun updateSuggestions(input: String, index: Int?) { - if (index == null || input.length < 2) { - suggestions.clear() - return + LaunchedEffect(uiState.scrollToFieldIndex) { + uiState.scrollToFieldIndex?.let { index -> + inputFieldPositions[index]?.let { position -> + scrollState.animateScrollTo(position) + } + currentOnScrollComplete() } + } - suggestions.clear() - if (input.isNotEmpty()) { - val filtered = bip39Words.filter { it.startsWith(input.lowercase()) }.take(3) - if (filtered.size == 1 && filtered.firstOrNull() == input) return - suggestions.addAll(filtered) + LaunchedEffect(uiState.focusedIndex) { + uiState.focusedIndex?.let { index -> + focusRequesters[index].requestFocus() } } @@ -117,9 +153,10 @@ fun RestoreWalletView( topBar = { AppTopBar( titleText = null, - onBackClick = onBackClick, + onBackClick = onBack, ) - } + }, + modifier = modifier, ) { paddingValues -> Box( modifier = Modifier @@ -134,76 +171,30 @@ fun RestoreWalletView( .verticalScroll(scrollState) ) { Display(stringResource(R.string.onboarding__restore_header).withAccent(accentColor = Colors.Blue)) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) BodyM( text = stringResource(R.string.onboarding__restore_phrase), color = Colors.White80, ) - Spacer(modifier = Modifier.height(32.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + VerticalSpacer(32.dp) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { // First column (1-6 or 1-12) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f) ) { - for (index in 0 until wordsPerColumn) { + for (index in 0 until uiState.wordsPerColumn) { MnemonicInputField( label = "${index + 1}.", - value = if (index == 0) firstFieldText else words[index], - isError = index in invalidWordIndices, - onValueChanged = { newValue -> - if (index == 0) { - if (newValue.contains(" ")) { - handlePastedWords( - newValue, - words, - onWordCountChanged = { is24Words = it }, - onFirstWordChanged = { firstFieldText = it }, - onInvalidWords = { invalidIndices -> - invalidWordIndices.clear() - invalidWordIndices.addAll(invalidIndices) - } - ) - } else { - updateWordValidity( - newValue, - index, - words, - invalidWordIndices, - onWordUpdate = { firstFieldText = it } - ) - updateSuggestions(newValue, focusedIndex) - } - } else { - updateWordValidity( - newValue, - index, - words, - invalidWordIndices, - ) - updateSuggestions(newValue, focusedIndex) - } - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - }, - onFocusChanged = { focused -> - if (focused) { - focusedIndex = index - updateSuggestions(if (index == 0) firstFieldText else words[index], index) - - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - } else if (focusedIndex == index) { - focusedIndex = null - suggestions.clear() - } - }, - onPositionChanged = { position -> - inputFieldPositions[index] = position - }, + value = uiState.words[index], + isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, + onValueChange = { onChangeWord(index, it) }, + onFocusChange = { focused -> onChangeWordFocus(index, focused) }, + onPositionChange = { position -> inputFieldPositions[index] = position }, + onBackspaceInEmpty = { onBackspaceInEmpty(index) }, + focusRequester = focusRequesters[index], index = index, ) } @@ -213,58 +204,28 @@ fun RestoreWalletView( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f) ) { - for (index in wordsPerColumn until (wordsPerColumn * 2)) { + for (index in uiState.wordsPerColumn until (uiState.wordsPerColumn * 2)) { MnemonicInputField( label = "${index + 1}.", - value = words[index], - isError = index in invalidWordIndices, - onValueChanged = { newValue -> - words[index] = newValue - - updateWordValidity( - newValue, - index, - words, - invalidWordIndices, - ) - updateSuggestions(newValue, focusedIndex) - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - }, - onFocusChanged = { focused -> - if (focused) { - focusedIndex = index - updateSuggestions(words[index], index) - - coroutineScope.launch { - inputFieldPositions[index]?.let { scrollState.animateScrollTo(it) } - } - } else if (focusedIndex == index) { - focusedIndex = null - suggestions.clear() - } - }, - onPositionChanged = { position -> - inputFieldPositions[index] = position - }, + value = uiState.words[index], + isError = index in uiState.invalidWordIndices && uiState.focusedIndex != index, + onValueChange = { onChangeWord(index, it) }, + onFocusChange = { focused -> onChangeWordFocus(index, focused) }, + onPositionChange = { position -> inputFieldPositions[index] = position }, + onBackspaceInEmpty = { onBackspaceInEmpty(index) }, + focusRequester = focusRequesters[index], index = index, ) } } } + // Passphrase - if (showingPassphrase) { - OutlinedTextField( - value = bip39Passphrase, - onValueChange = { bip39Passphrase = it }, - placeholder = { - Text( - text = stringResource(R.string.onboarding__restore_passphrase_placeholder) - ) - }, - shape = RoundedCornerShape(8.dp), - colors = AppTextFieldDefaults.semiTransparent, + if (uiState.showingPassphrase) { + TextInput( + value = uiState.bip39Passphrase, + onValueChange = onChangePassphrase, + placeholder = stringResource(R.string.onboarding__restore_passphrase_placeholder), singleLine = true, keyboardOptions = KeyboardOptions( autoCorrectEnabled = false, @@ -276,7 +237,7 @@ fun RestoreWalletView( .padding(top = 4.dp) .testTag("PassphraseInput") ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BodyS( text = stringResource(R.string.onboarding__restore_passphrase_meaning), color = Colors.White64, @@ -290,7 +251,7 @@ fun RestoreWalletView( .weight(1f) ) - AnimatedVisibility(visible = invalidWordIndices.isNotEmpty()) { + AnimatedVisibility(visible = uiState.invalidWordIndices.any { it != uiState.focusedIndex }) { BodyS( text = stringResource( R.string.onboarding__restore_red_explain @@ -314,20 +275,10 @@ fun RestoreWalletView( .padding(vertical = 16.dp) .fillMaxWidth(), ) { - val areButtonsEnabled by remember { - derivedStateOf { - val wordCount = if (is24Words) 24 else 12 - words.subList(0, wordCount) - .none { it.isBlank() } && invalidWordIndices.isEmpty() && !checksumErrorVisible - } - } - AnimatedVisibility(visible = !showingPassphrase, modifier = Modifier.weight(1f)) { + AnimatedVisibility(visible = !uiState.showingPassphrase, modifier = Modifier.weight(1f)) { SecondaryButton( text = stringResource(R.string.onboarding__advanced), - onClick = { - showingPassphrase = !showingPassphrase - bip39Passphrase = "" - }, + onClick = { onAdvancedClick() }, enabled = areButtonsEnabled, modifier = Modifier .weight(1f) @@ -337,7 +288,7 @@ fun RestoreWalletView( PrimaryButton( text = stringResource(R.string.onboarding__restore), onClick = { - onRestoreClick(bip39Mnemonic, bip39Passphrase.takeIf { it.isNotEmpty() }) + onRestore(uiState.bip39Mnemonic, uiState.bip39Passphrase.takeIf { it.isNotEmpty() }) }, enabled = areButtonsEnabled, modifier = Modifier @@ -347,62 +298,51 @@ fun RestoreWalletView( } } - // Suggestions row - AnimatedVisibility( - visible = suggestions.isNotEmpty(), - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), + SuggestionsRow( + suggestions = uiState.suggestions, + onSelect = { onSelectSuggestion(it) } + ) + } + } +} + +@Composable +private fun BoxScope.SuggestionsRow( + suggestions: List, + onSelect: (String) -> Unit, +) { + AnimatedVisibility( + visible = suggestions.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + modifier = Modifier + .align(Alignment.BottomCenter) + .imePadding() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Colors.Black) + .padding(horizontal = 32.dp, vertical = 8.dp) + ) { + BodyS( + text = stringResource(R.string.onboarding__restore_suggestions), + color = Colors.White64, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .align(Alignment.BottomCenter) - .imePadding() + .fillMaxWidth() + .padding(top = 12.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(Colors.Black) - .padding(horizontal = 32.dp, vertical = 8.dp) - ) { - BodyS( - text = stringResource(R.string.onboarding__restore_suggestions), - color = Colors.White64, + suggestions.forEach { suggestion -> + PrimaryButton( + text = suggestion, + onClick = { onSelect(suggestion) }, + size = ButtonSize.Small, + fullWidth = false ) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - ) { - suggestions.forEach { suggestion -> - PrimaryButton( - text = suggestion, - onClick = { - focusedIndex?.let { index -> - if (index == 0) { - firstFieldText = suggestion - updateWordValidity( - suggestion, - index, - words, - invalidWordIndices, - onWordUpdate = { firstFieldText = it } - ) - } else { - updateWordValidity( - suggestion, - index, - words, - invalidWordIndices, - ) - } - suggestions.clear() - } - }, - size = ButtonSize.Small, - fullWidth = false - ) - } - } } } } @@ -414,14 +354,30 @@ fun MnemonicInputField( label: String, isError: Boolean = false, value: String, - onValueChanged: (String) -> Unit, - onFocusChanged: (Boolean) -> Unit, - onPositionChanged: (Int) -> Unit, + onValueChange: (String) -> Unit, + onFocusChange: (Boolean) -> Unit, + onPositionChange: (Int) -> Unit, + onBackspaceInEmpty: () -> Unit, + focusRequester: FocusRequester, index: Int, ) { + var textFieldValue by remember { mutableStateOf(TextFieldValue()) } + + // Sync text from parent while preserving selection + LaunchedEffect(value) { + if (textFieldValue.text != value) { + val selection = textFieldValue.selection + textFieldValue = TextFieldValue(value, selection) + } + } + OutlinedTextField( - value = value, - onValueChange = onValueChanged, + value = textFieldValue, + onValueChange = { + textFieldValue = it + onValueChange(it.text) + }, + textStyle = AppTextStyles.BodySSB, prefix = { Text( text = label, @@ -440,70 +396,95 @@ fun MnemonicInputField( capitalization = KeyboardCapitalization.None, ), modifier = Modifier + .focusRequester(focusRequester) + .onPreviewKeyEvent { keyEvent -> + if (keyEvent.key == Key.Backspace && + keyEvent.type == KeyEventType.KeyDown && + value.isEmpty() + ) { + onBackspaceInEmpty() + true + } else { + false + } + } .testTag("Word-$index") - .onFocusChanged { onFocusChanged(it.isFocused) } + .onFocusChanged { focusState -> onFocusChange(focusState.isFocused) } .onGloballyPositioned { coordinates -> val position = coordinates.positionInParent().y.toInt() * 2 // double the scroll to ensure enough space - onPositionChanged(position) + onPositionChange(position) } ) } -private fun handlePastedWords( - pastedText: String, - words: SnapshotStateList, - onWordCountChanged: (Boolean) -> Unit, - onFirstWordChanged: (String) -> Unit, - onInvalidWords: (List) -> Unit, -) { - val pastedWords = pastedText.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } - if (pastedWords.size == 12 || pastedWords.size == 24) { - val invalidWordIndices = pastedWords.withIndex() - .filter { !it.value.isBip39() } - .map { it.index } - - if (invalidWordIndices.isNotEmpty()) { - onInvalidWords(invalidWordIndices) - } +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState(), + checksumErrorVisible = false, + areButtonsEnabled = false, + ) + } +} - onWordCountChanged(pastedWords.size == 24) - for (index in pastedWords.indices) { - words[index] = pastedWords[index] - } - for (index in pastedWords.size until words.size) { - words[index] = "" - } - onFirstWordChanged(pastedWords.first()) +@Preview(showSystemUi = true) +@Composable +private fun PreviewAdvanced() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState( + showingPassphrase = true, + ), + checksumErrorVisible = false, + areButtonsEnabled = false, + ) } } -private fun updateWordValidity( - newValue: String, - index: Int, - words: SnapshotStateList, - invalidWordIndices: SnapshotStateList, - onWordUpdate: ((String) -> Unit)? = null, -) { - words[index] = newValue - onWordUpdate?.invoke(newValue) +@Preview(showSystemUi = true) +@Composable +private fun PreviewValid() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState( + words = List(12) { if (it % 2 == 0) "abandon" else "ability" }, + is24Words = false, + ), + checksumErrorVisible = false, + areButtonsEnabled = true, + ) + } +} - val isValid = newValue.isBip39() - if (!isValid && newValue.isNotEmpty()) { - if (!invalidWordIndices.contains(index)) { - invalidWordIndices.add(index) - } - } else { - invalidWordIndices.remove(index) +@Preview(showSystemUi = true) +@Composable +private fun PreviewInvalid() { + AppThemeSurface { + Content( + uiState = RestoreWalletUiState( + words = List(12) { if (it % 2 == 0) "rock" else "roll" }, + is24Words = false, + ), + checksumErrorVisible = true, + areButtonsEnabled = false, + ) } } @Preview(showSystemUi = true) @Composable -fun RestoreWalletViewPreview() { +private fun Preview24Words() { AppThemeSurface { - RestoreWalletView( - onBackClick = {}, - onRestoreClick = { _, _ -> }, + @Suppress("MagicNumber") + Content( + uiState = RestoreWalletUiState( + is24Words = true, + words = List(24) { "word${it + 1}" } + ), + checksumErrorVisible = false, + areButtonsEnabled = false, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index 7e091afb3..2701817eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -161,10 +161,9 @@ fun HomeScreen( drawerState = drawerState, latestActivities = latestActivities, onRefresh = { - activityListViewModel.fetchLatestActivities() + activityListViewModel.resync() walletViewModel.onPullToRefresh() homeViewModel.refreshWidgets() - activityListViewModel.syncLdkNodePayments() }, onClickProfile = { if (!hasSeenProfileIntro) { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 318ea6f72..2c77ff150 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -155,7 +155,7 @@ fun ActivityDetailScreen( title = context.getString(R.string.wallet__boost_success_title), description = context.getString(R.string.wallet__boost_success_msg) ) - listViewModel.fetchLatestActivities() + listViewModel.resync() onCloseClick() }, onFailure = { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt index 5eab26ba1..99a8de58a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/DateRangeSelectorSheet.kt @@ -647,7 +647,7 @@ private fun PreviewWithSelection() { BottomSheetPreview { Content( initialStartDate = Clock.System.now() - .minus(CalendarConstants.PREVIEW_DAYS_AGO.days) + .minus(CalendarConstants.DAYS_IN_WEEK.days) .toEpochMilliseconds(), initialEndDate = Clock.System.now().toEpochMilliseconds(), ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt index 540c02c2e..c97ea3da0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityIcon.kt @@ -27,6 +27,7 @@ import to.bitkit.R import to.bitkit.ext.isBoosted import to.bitkit.ext.isFinished import to.bitkit.ext.isTransfer +import to.bitkit.ext.txType import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -41,10 +42,7 @@ fun ActivityIcon( is Activity.Lightning -> activity.v1.status is Activity.Onchain -> null } - val txType: PaymentType = when (activity) { - is Activity.Lightning -> activity.v1.txType - is Activity.Onchain -> activity.v1.txType - } + val txType: PaymentType = activity.txType() val arrowIcon = painterResource(if (txType == PaymentType.SENT) R.drawable.ic_sent else R.drawable.ic_received) when { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt index 84a331fb7..2d6912880 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/components/ActivityRow.kt @@ -31,6 +31,7 @@ import to.bitkit.ext.isSent import to.bitkit.ext.isTransfer import to.bitkit.ext.rawId import to.bitkit.ext.totalValue +import to.bitkit.ext.txType import to.bitkit.models.PrimaryDisplay import to.bitkit.models.formatToModernDisplay import to.bitkit.ui.LocalCurrencies @@ -64,10 +65,7 @@ fun ActivityRow( is Activity.Lightning -> item.v1.timestamp is Activity.Onchain -> item.v1.timestamp } - val txType: PaymentType = when (item) { - is Activity.Lightning -> item.v1.txType - is Activity.Onchain -> item.v1.txType - } + val txType: PaymentType = item.txType() val isSent = item.isSent() val amountPrefix = if (isSent) "-" else "+" val confirmed: Boolean? = when (item) { diff --git a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt index bf4a1e942..99962eafa 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BackupSettingsScreen.kt @@ -28,7 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R import to.bitkit.env.Env -import to.bitkit.ext.toLocalizedTimestamp +import to.bitkit.ext.toRelativeTimeString import to.bitkit.models.BackupCategory import to.bitkit.models.BackupItemStatus import to.bitkit.ui.Routes @@ -156,13 +156,18 @@ private fun BackupStatusItem( ) { val status = uiState.status + val timeString = if (status.synced == 0L) { + stringResource(R.string.common__never) + } else { + status.synced.toRelativeTimeString() + } + val subtitle = when { - status.running -> "Running" // TODO add missing localized text + status.running -> stringResource(R.string.settings__backup__status_running) !status.isRequired -> stringResource(R.string.settings__backup__status_success) - .replace("{time}", status.synced.toLocalizedTimestamp()) - + .replace("{time}", timeString) else -> stringResource(R.string.settings__backup__status_failed) - .replace("{time}", status.synced.toLocalizedTimestamp()) + .replace("{time}", timeString) } Row( diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt index 1abf43da4..8b7166a0c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ConfirmMnemonicScreen.kt @@ -37,7 +37,6 @@ import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.utils.bip39Words @Composable fun ConfirmMnemonicScreen( @@ -226,7 +225,7 @@ private fun SelectedWordItem( BodyMSB(text = "$number.", color = Colors.White64) Spacer(modifier = Modifier.width(4.dp)) BodyMSB( - text = if (word.isEmpty()) "" else word, + text = word.ifEmpty { "" }, color = if (word.isEmpty()) Colors.White64 else if (isCorrect) Colors.Green else Colors.Red ) } @@ -235,7 +234,7 @@ private fun SelectedWordItem( @Preview(showSystemUi = true) @Composable private fun Preview() { - val testWords = bip39Words.take(12) + val testWords = List(12) { "word${it + 1}" } AppThemeSurface { ConfirmMnemonicContent( originalSeed = testWords, @@ -253,7 +252,7 @@ private fun Preview() { @Preview(showSystemUi = true) @Composable private fun Preview2() { - val testWords = bip39Words.take(12) + val testWords = List(12) { "word${it + 1}" } val half = testWords.size / 2 AppThemeSurface { ConfirmMnemonicContent( @@ -272,7 +271,7 @@ private fun Preview2() { @Preview(showSystemUi = true) @Composable private fun Preview24Words() { - val testWords = bip39Words.take(24) + val testWords = List(24) { "word${it + 1}" } val half = testWords.size / 2 AppThemeSurface { ConfirmMnemonicContent( diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt index a6259df85..4da79b388 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt @@ -54,7 +54,6 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.ui.utils.withAccent -import to.bitkit.utils.bip39Words @Composable fun ShowMnemonicScreen( @@ -207,7 +206,7 @@ private fun Preview() { BottomSheetPreview { var showMnemonic by remember { mutableStateOf(false) } ShowMnemonicContent( - mnemonic = bip39Words.take(12).joinToString(" "), + mnemonic = List(12) { "word${it + 1}" }.joinToString(" "), showMnemonic = showMnemonic, onRevealClick = { showMnemonic = !showMnemonic }, onCopyClick = {}, @@ -224,7 +223,7 @@ private fun PreviewShown() { AppThemeSurface { BottomSheetPreview { ShowMnemonicContent( - mnemonic = bip39Words.take(12).joinToString(" "), + mnemonic = List(12) { "word${it + 1}" }.joinToString(" "), showMnemonic = true, onRevealClick = {}, onCopyClick = {}, @@ -241,7 +240,7 @@ private fun Preview24Words() { AppThemeSurface { BottomSheetPreview { ShowMnemonicContent( - mnemonic = bip39Words.take(24).joinToString(" "), + mnemonic = List(24) { "word${it + 1}" }.joinToString(" "), showMnemonic = true, onRevealClick = {}, onCopyClick = {}, diff --git a/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt new file mode 100644 index 000000000..a836be49b --- /dev/null +++ b/app/src/main/java/to/bitkit/usecases/WipeWalletUseCase.kt @@ -0,0 +1,70 @@ +package to.bitkit.usecases + +import to.bitkit.data.AppDb +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.services.CoreService +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +@Suppress("LongParameterList") +@Singleton +class WipeWalletUseCase @Inject constructor( + private val backupRepo: BackupRepo, + private val keychain: Keychain, + private val coreService: CoreService, + private val db: AppDb, + private val settingsStore: SettingsStore, + private val cacheStore: CacheStore, + private val widgetsStore: WidgetsStore, + private val blocktankRepo: BlocktankRepo, + private val activityRepo: ActivityRepo, + private val lightningRepo: LightningRepo, +) { + @Suppress("TooGenericExceptionCaught") + suspend operator fun invoke( + walletIndex: Int = 0, + resetWalletState: () -> Unit, + onSuccess: () -> Unit, + ): Result { + try { + backupRepo.setWiping(true) + backupRepo.reset() + + keychain.wipe() + + coreService.wipeData() + db.clearAllTables() + + settingsStore.reset() + cacheStore.reset() + widgetsStore.reset() + + blocktankRepo.resetState() + activityRepo.resetState() + resetWalletState() + + return lightningRepo.wipeStorage(walletIndex) + .onSuccess { + onSuccess() + Logger.reset() + } + } catch (e: Throwable) { + Logger.error("Wipe wallet error", e, context = TAG) + return Result.failure(e) + } finally { + backupRepo.setWiping(false) + } + } + + companion object Companion { + const val TAG = "WipeWalletUseCase" + } +} diff --git a/app/src/main/java/to/bitkit/utils/Bip39Utils.kt b/app/src/main/java/to/bitkit/utils/Bip39Utils.kt deleted file mode 100644 index c00b45a61..000000000 --- a/app/src/main/java/to/bitkit/utils/Bip39Utils.kt +++ /dev/null @@ -1,56 +0,0 @@ -package to.bitkit.utils - -import java.security.MessageDigest - -val bip39Words = - setOf("abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual", "adapt", "add", "addict", "address", "adjust", "admit", "adult", "advance", "advice", "aerobic", "affair", "afford", "afraid", "again", "age", "agent", "agree", "ahead", "aim", "air", "airport", "aisle", "alarm", "album", "alcohol", "alert", "alien", "all", "alley", "allow", "almost", "alone", "alpha", "already", "also", "alter", "always", "amateur", "amazing", "among", "amount", "amused", "analyst", "anchor", "ancient", "anger", "angle", "angry", "animal", "ankle", "announce", "annual", "another", "answer", "antenna", "antique", "anxiety", "any", "apart", "apology", "appear", "apple", "approve", "april", "arch", "arctic", "area", "arena", "argue", "arm", "armed", "armor", "army", "around", "arrange", "arrest", "arrive", "arrow", "art", "artefact", "artist", "artwork", "ask", "aspect", "assault", "asset", "assist", "assume", "asthma", "athlete", "atom", "attack", "attend", "attitude", "attract", "auction", "audit", "august", "aunt", "author", "auto", "autumn", "average", "avocado", "avoid", "awake", "aware", "away", "awesome", "awful", "awkward", "axis", "baby", "bachelor", "bacon", "badge", "bag", "balance", "balcony", "ball", "bamboo", "banana", "banner", "bar", "barely", "bargain", "barrel", "base", "basic", "basket", "battle", "beach", "bean", "beauty", "because", "become", "beef", "before", "begin", "behave", "behind", "believe", "below", "belt", "bench", "benefit", "best", "betray", "better", "between", "beyond", "bicycle", "bid", "bike", "bind", "biology", "bird", "birth", "bitter", "black", "blade", "blame", "blanket", "blast", "bleak", "bless", "blind", "blood", "blossom", "blouse", "blue", "blur", "blush", "board", "boat", "body", "boil", "bomb", "bone", "bonus", "book", "boost", "border", "boring", "borrow", "boss", "bottom", "bounce", "box", "boy", "bracket", "brain", "brand", "brass", "brave", "bread", "breeze", "brick", "bridge", "brief", "bright", "bring", "brisk", "broccoli", "broken", "bronze", "broom", "brother", "brown", "brush", "bubble", "buddy", "budget", "buffalo", "build", "bulb", "bulk", "bullet", "bundle", "bunker", "burden", "burger", "burst", "bus", "business", "busy", "butter", "buyer", "buzz", "cabbage", "cabin", "cable", "cactus", "cage", "cake", "call", "calm", "camera", "camp", "can", "canal", "cancel", "candy", "cannon", "canoe", "canvas", "canyon", "capable", "capital", "captain", "car", "carbon", "card", "cargo", "carpet", "carry", "cart", "case", "cash", "casino", "castle", "casual", "cat", "catalog", "catch", "category", "cattle", "caught", "cause", "caution", "cave", "ceiling", "celery", "cement", "census", "century", "cereal", "certain", "chair", "chalk", "champion", "change", "chaos", "chapter", "charge", "chase", "chat", "cheap", "check", "cheese", "chef", "cherry", "chest", "chicken", "chief", "child", "chimney", "choice", "choose", "chronic", "chuckle", "chunk", "churn", "cigar", "cinnamon", "circle", "citizen", "city", "civil", "claim", "clap", "clarify", "claw", "clay", "clean", "clerk", "clever", "click", "client", "cliff", "climb", "clinic", "clip", "clock", "clog", "close", "cloth", "cloud", "clown", "club", "clump", "cluster", "clutch", "coach", "coast", "coconut", "code", "coffee", "coil", "coin", "collect", "color", "column", "combine", "come", "comfort", "comic", "common", "company", "concert", "conduct", "confirm", "congress", "connect", "consider", "control", "convince", "cook", "cool", "copper", "copy", "coral", "core", "corn", "correct", "cost", "cotton", "couch", "country", "couple", "course", "cousin", "cover", "coyote", "crack", "cradle", "craft", "cram", "crane", "crash", "crater", "crawl", "crazy", "cream", "credit", "creek", "crew", "cricket", "crime", "crisp", "critic", "crop", "cross", "crouch", "crowd", "crucial", "cruel", "cruise", "crumble", "crunch", "crush", "cry", "crystal", "cube", "culture", "cup", "cupboard", "curious", "current", "curtain", "curve", "cushion", "custom", "cute", "cycle", "dad", "damage", "damp", "dance", "danger", "daring", "dash", "daughter", "dawn", "day", "deal", "debate", "debris", "decade", "december", "decide", "decline", "decorate", "decrease", "deer", "defense", "define", "defy", "degree", "delay", "deliver", "demand", "demise", "denial", "dentist", "deny", "depart", "depend", "deposit", "depth", "deputy", "derive", "describe", "desert", "design", "desk", "despair", "destroy", "detail", "detect", "develop", "device", "devote", "diagram", "dial", "diamond", "diary", "dice", "diesel", "diet", "differ", "digital", "dignity", "dilemma", "dinner", "dinosaur", "direct", "dirt", "disagree", "discover", "disease", "dish", "dismiss", "disorder", "display", "distance", "divert", "divide", "divorce", "dizzy", "doctor", "document", "dog", "doll", "dolphin", "domain", "donate", "donkey", "donor", "door", "dose", "double", "dove", "draft", "dragon", "drama", "drastic", "draw", "dream", "dress", "drift", "drill", "drink", "drip", "drive", "drop", "drum", "dry", "duck", "dumb", "dune", "during", "dust", "dutch", "duty", "dwarf", "dynamic", "eager", "eagle", "early", "earn", "earth", "easily", "east", "easy", "echo", "ecology", "economy", "edge", "edit", "educate", "effort", "egg", "eight", "either", "elbow", "elder", "electric", "elegant", "element", "elephant", "elevator", "elite", "else", "embark", "embody", "embrace", "emerge", "emotion", "employ", "empower", "empty", "enable", "enact", "end", "endless", "endorse", "enemy", "energy", "enforce", "engage", "engine", "enhance", "enjoy", "enlist", "enough", "enrich", "enroll", "ensure", "enter", "entire", "entry", "envelope", "episode", "equal", "equip", "era", "erase", "erode", "erosion", "error", "erupt", "escape", "essay", "essence", "estate", "eternal", "ethics", "evidence", "evil", "evoke", "evolve", "exact", "example", "excess", "exchange", "excite", "exclude", "excuse", "execute", "exercise", "exhaust", "exhibit", "exile", "exist", "exit", "exotic", "expand", "expect", "expire", "explain", "expose", "express", "extend", "extra", "eye", "eyebrow", "fabric", "face", "faculty", "fade", "faint", "faith", "fall", "false", "fame", "family", "famous", "fan", "fancy", "fantasy", "farm", "fashion", "fat", "fatal", "father", "fatigue", "fault", "favorite", "feature", "february", "federal", "fee", "feed", "feel", "female", "fence", "festival", "fetch", "fever", "few", "fiber", "fiction", "field", "figure", "file", "film", "filter", "final", "find", "fine", "finger", "finish", "fire", "firm", "first", "fiscal", "fish", "fit", "fitness", "fix", "flag", "flame", "flash", "flat", "flavor", "flee", "flight", "flip", "float", "flock", "floor", "flower", "fluid", "flush", "fly", "foam", "focus", "fog", "foil", "fold", "follow", "food", "foot", "force", "forest", "forget", "fork", "fortune", "forum", "forward", "fossil", "foster", "found", "fox", "fragile", "frame", "frequent", "fresh", "friend", "fringe", "frog", "front", "frost", "frown", "frozen", "fruit", "fuel", "fun", "funny", "furnace", "fury", "future", "gadget", "gain", "galaxy", "gallery", "game", "gap", "garage", "garbage", "garden", "garlic", "garment", "gas", "gasp", "gate", "gather", "gauge", "gaze", "general", "genius", "genre", "gentle", "genuine", "gesture", "ghost", "giant", "gift", "giggle", "ginger", "giraffe", "girl", "give", "glad", "glance", "glare", "glass", "glide", "glimpse", "globe", "gloom", "glory", "glove", "glow", "glue", "goat", "goddess", "gold", "good", "goose", "gorilla", "gospel", "gossip", "govern", "gown", "grab", "grace", "grain", "grant", "grape", "grass", "gravity", "great", "green", "grid", "grief", "grit", "grocery", "group", "grow", "grunt", "guard", "guess", "guide", "guilt", "guitar", "gun", "gym", "habit", "hair", "half", "hammer", "hamster", "hand", "happy", "harbor", "hard", "harsh", "harvest", "hat", "have", "hawk", "hazard", "head", "health", "heart", "heavy", "hedgehog", "height", "hello", "helmet", "help", "hen", "hero", "hidden", "high", "hill", "hint", "hip", "hire", "history", "hobby", "hockey", "hold", "hole", "holiday", "hollow", "home", "honey", "hood", "hope", "horn", "horror", "horse", "hospital", "host", "hotel", "hour", "hover", "hub", "huge", "human", "humble", "humor", "hundred", "hungry", "hunt", "hurdle", "hurry", "hurt", "husband", "hybrid", "ice", "icon", "idea", "identify", "idle", "ignore", "ill", "illegal", "illness", "image", "imitate", "immense", "immune", "impact", "impose", "improve", "impulse", "inch", "include", "income", "increase", "index", "indicate", "indoor", "industry", "infant", "inflict", "inform", "inhale", "inherit", "initial", "inject", "injury", "inmate", "inner", "innocent", "input", "inquiry", "insane", "insect", "inside", "inspire", "install", "intact", "interest", "into", "invest", "invite", "involve", "iron", "island", "isolate", "issue", "item", "ivory", "jacket", "jaguar", "jar", "jazz", "jealous", "jeans", "jelly", "jewel", "job", "join", "joke", "journey", "joy", "judge", "juice", "jump", "jungle", "junior", "junk", "just", "kangaroo", "keen", "keep", "ketchup", "key", "kick", "kid", "kidney", "kind", "kingdom", "kiss", "kit", "kitchen", "kite", "kitten", "kiwi", "knee", "knife", "knock", "know", "lab", "label", "labor", "ladder", "lady", "lake", "lamp", "language", "laptop", "large", "later", "latin", "laugh", "laundry", "lava", "law", "lawn", "lawsuit", "layer", "lazy", "leader", "leaf", "learn", "leave", "lecture", "left", "leg", "legal", "legend", "leisure", "lemon", "lend", "length", "lens", "leopard", "lesson", "letter", "level", "liar", "liberty", "library", "license", "life", "lift", "light", "like", "limb", "limit", "link", "lion", "liquid", "list", "little", "live", "lizard", "load", "loan", "lobster", "local", "lock", "logic", "lonely", "long", "loop", "lottery", "loud", "lounge", "love", "loyal", "lucky", "luggage", "lumber", "lunar", "lunch", "luxury", "lyrics", "machine", "mad", "magic", "magnet", "maid", "mail", "main", "major", "make", "mammal", "man", "manage", "mandate", "mango", "mansion", "manual", "maple", "marble", "march", "margin", "marine", "market", "marriage", "mask", "mass", "master", "match", "material", "math", "matrix", "matter", "maximum", "maze", "meadow", "mean", "measure", "meat", "mechanic", "medal", "media", "melody", "melt", "member", "memory", "mention", "menu", "mercy", "merge", "merit", "merry", "mesh", "message", "metal", "method", "middle", "midnight", "milk", "million", "mimic", "mind", "minimum", "minor", "minute", "miracle", "mirror", "misery", "miss", "mistake", "mix", "mixed", "mixture", "mobile", "model", "modify", "mom", "moment", "monitor", "monkey", "monster", "month", "moon", "moral", "more", "morning", "mosquito", "mother", "motion", "motor", "mountain", "mouse", "move", "movie", "much", "muffin", "mule", "multiply", "muscle", "museum", "mushroom", "music", "must", "mutual", "myself", "mystery", "myth", "naive", "name", "napkin", "narrow", "nasty", "nation", "nature", "near", "neck", "need", "negative", "neglect", "neither", "nephew", "nerve", "nest", "net", "network", "neutral", "never", "news", "next", "nice", "night", "noble", "noise", "nominee", "noodle", "normal", "north", "nose", "notable", "note", "nothing", "notice", "novel", "now", "nuclear", "number", "nurse", "nut", "oak", "obey", "object", "oblige", "obscure", "observe", "obtain", "obvious", "occur", "ocean", "october", "odor", "off", "offer", "office", "often", "oil", "okay", "old", "olive", "olympic", "omit", "once", "one", "onion", "online", "only", "open", "opera", "opinion", "oppose", "option", "orange", "orbit", "orchard", "order", "ordinary", "organ", "orient", "original", "orphan", "ostrich", "other", "outdoor", "outer", "output", "outside", "oval", "oven", "over", "own", "owner", "oxygen", "oyster", "ozone", "pact", "paddle", "page", "pair", "palace", "palm", "panda", "panel", "panic", "panther", "paper", "parade", "parent", "park", "parrot", "party", "pass", "patch", "path", "patient", "patrol", "pattern", "pause", "pave", "payment", "peace", "peanut", "pear", "peasant", "pelican", "pen", "penalty", "pencil", "people", "pepper", "perfect", "permit", "person", "pet", "phone", "photo", "phrase", "physical", "piano", "picnic", "picture", "piece", "pig", "pigeon", "pill", "pilot", "pink", "pioneer", "pipe", "pistol", "pitch", "pizza", "place", "planet", "plastic", "plate", "play", "please", "pledge", "pluck", "plug", "plunge", "poem", "poet", "point", "polar", "pole", "police", "pond", "pony", "pool", "popular", "portion", "position", "possible", "post", "potato", "pottery", "poverty", "powder", "power", "practice", "praise", "predict", "prefer", "prepare", "present", "pretty", "prevent", "price", "pride", "primary", "print", "priority", "prison", "private", "prize", "problem", "process", "produce", "profit", "program", "project", "promote", "proof", "property", "prosper", "protect", "proud", "provide", "public", "pudding", "pull", "pulp", "pulse", "pumpkin", "punch", "pupil", "puppy", "purchase", "purity", "purpose", "purse", "push", "put", "puzzle", "pyramid", "quality", "quantum", "quarter", "question", "quick", "quit", "quiz", "quote", "rabbit", "raccoon", "race", "rack", "radar", "radio", "rail", "rain", "raise", "rally", "ramp", "ranch", "random", "range", "rapid", "rare", "rate", "rather", "raven", "raw", "razor", "ready", "real", "reason", "rebel", "rebuild", "recall", "receive", "recipe", "record", "recycle", "reduce", "reflect", "reform", "refuse", "region", "regret", "regular", "reject", "relax", "release", "relief", "rely", "remain", "remember", "remind", "remove", "render", "renew", "rent", "reopen", "repair", "repeat", "replace", "report", "require", "rescue", "resemble", "resist", "resource", "response", "result", "retire", "retreat", "return", "reunion", "reveal", "review", "reward", "rhythm", "rib", "ribbon", "rice", "rich", "ride", "ridge", "rifle", "right", "rigid", "ring", "riot", "ripple", "risk", "ritual", "rival", "river", "road", "roast", "robot", "robust", "rocket", "romance", "roof", "rookie", "room", "rose", "rotate", "rough", "round", "route", "royal", "rubber", "rude", "rug", "rule", "run", "runway", "rural", "sad", "saddle", "sadness", "safe", "sail", "salad", "salmon", "salon", "salt", "salute", "same", "sample", "sand", "satisfy", "satoshi", "sauce", "sausage", "save", "say", "scale", "scan", "scare", "scatter", "scene", "scheme", "school", "science", "scissors", "scorpion", "scout", "scrap", "screen", "script", "scrub", "sea", "search", "season", "seat", "second", "secret", "section", "security", "seed", "seek", "segment", "select", "sell", "seminar", "senior", "sense", "sentence", "series", "service", "session", "settle", "setup", "seven", "shadow", "shaft", "shallow", "share", "shed", "shell", "sheriff", "shield", "shift", "shine", "ship", "shiver", "shock", "shoe", "shoot", "shop", "short", "shoulder", "shove", "shrimp", "shrug", "shuffle", "shy", "sibling", "sick", "side", "siege", "sight", "sign", "silent", "silk", "silly", "silver", "similar", "simple", "since", "sing", "siren", "sister", "situate", "six", "size", "skate", "sketch", "ski", "skill", "skin", "skirt", "skull", "slab", "slam", "sleep", "slender", "slice", "slide", "slight", "slim", "slogan", "slot", "slow", "slush", "small", "smart", "smile", "smoke", "smooth", "snack", "snake", "snap", "sniff", "snow", "soap", "soccer", "social", "sock", "soda", "soft", "solar", "soldier", "solid", "solution", "solve", "someone", "song", "soon", "sorry", "sort", "soul", "sound", "soup", "source", "south", "space", "spare", "spatial", "spawn", "speak", "special", "speed", "spell", "spend", "sphere", "spice", "spider", "spike", "spin", "spirit", "split", "spoil", "sponsor", "spoon", "sport", "spot", "spray", "spread", "spring", "spy", "square", "squeeze", "squirrel", "stable", "stadium", "staff", "stage", "stairs", "stamp", "stand", "start", "state", "stay", "steak", "steel", "stem", "step", "stereo", "stick", "still", "sting", "stock", "stomach", "stone", "stool", "story", "stove", "strategy", "street", "strike", "strong", "struggle", "student", "stuff", "stumble", "style", "subject", "submit", "subway", "success", "such", "sudden", "suffer", "sugar", "suggest", "suit", "summer", "sun", "sunny", "sunset", "super", "supply", "supreme", "sure", "surface", "surge", "surprise", "surround", "survey", "suspect", "sustain", "swallow", "swamp", "swap", "swarm", "swear", "sweet", "swift", "swim", "swing", "switch", "sword", "symbol", "symptom", "syrup", "system", "table", "tackle", "tag", "tail", "talent", "talk", "tank", "tape", "target", "task", "taste", "tattoo", "taxi", "teach", "team", "tell", "ten", "tenant", "tennis", "tent", "term", "test", "text", "thank", "that", "theme", "then", "theory", "there", "they", "thing", "this", "thought", "three", "thrive", "throw", "thumb", "thunder", "ticket", "tide", "tiger", "tilt", "timber", "time", "tiny", "tip", "tired", "tissue", "title", "toast", "tobacco", "today", "toddler", "toe", "together", "toilet", "token", "tomato", "tomorrow", "tone", "tongue", "tonight", "tool", "tooth", "top", "topic", "topple", "torch", "tornado", "tortoise", "toss", "total", "tourist", "toward", "tower", "town", "toy", "track", "trade", "traffic", "tragic", "train", "transfer", "trap", "trash", "travel", "tray", "treat", "tree", "trend", "trial", "tribe", "trick", "trigger", "trim", "trip", "trophy", "trouble", "truck", "true", "truly", "trumpet", "trust", "truth", "try", "tube", "tuition", "tumble", "tuna", "tunnel", "turkey", "turn", "turtle", "twelve", "twenty", "twice", "twin", "twist", "two", "type", "typical", "ugly", "umbrella", "unable", "unaware", "uncle", "uncover", "under", "undo", "unfair", "unfold", "unhappy", "uniform", "unique", "unit", "universe", "unknown", "unlock", "until", "unusual", "unveil", "update", "upgrade", "uphold", "upon", "upper", "upset", "urban", "urge", "usage", "use", "used", "useful", "useless", "usual", "utility", "vacant", "vacuum", "vague", "valid", "valley", "valve", "van", "vanish", "vapor", "various", "vast", "vault", "vehicle", "velvet", "vendor", "venture", "venue", "verb", "verify", "version", "very", "vessel", "veteran", "viable", "vibrant", "vicious", "victory", "video", "view", "village", "vintage", "violin", "virtual", "virus", "visa", "visit", "visual", "vital", "vivid", "vocal", "voice", "void", "volcano", "volume", "vote", "voyage", "wage", "wagon", "wait", "walk", "wall", "walnut", "want", "warfare", "warm", "warrior", "wash", "wasp", "waste", "water", "wave", "way", "wealth", "weapon", "wear", "weasel", "weather", "web", "wedding", "weekend", "weird", "welcome", "west", "wet", "whale", "what", "wheat", "wheel", "when", "where", "whip", "whisper", "wide", "width", "wife", "wild", "will", "win", "window", "wine", "wing", "wink", "winner", "winter", "wire", "wisdom", "wise", "wish", "witness", "wolf", "woman", "wonder", "wood", "wool", "word", "work", "world", "worry", "worth", "wrap", "wreck", "wrestle", "wrist", "write", "wrong", "yard", "year", "yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo") - -fun String.isBip39() = bip39Words.contains(this.lowercase()) - -/** - * Validates a BIP39 mnemonic phrase by checking its checksum. This method only tests 12 or 24 words phrases - * - * @return True if the mnemonic is valid, false otherwise - */ -fun List.validBip39Checksum(): Boolean { - if (this.size != 12 && this.size != 24) { - return false - } - - if (this.any { !it.isBip39() }) return false - - val indices = this.map { word -> - val index = bip39Words.indexOf(word) - if (index == -1) { - return false - } - index - } - - // 12 words = 128 bits of entropy + 4 bits of checksum - // 24 words = 256 bits of entropy + 8 bits of checksum - val entropyBits = if (this.size == 12) 128 else 256 - val checksumBits = entropyBits / 32 - - val bits = indices.joinToString(separator = "") { index -> - index.toString(2).padStart(length = 11, padChar = '0') - } - - val entropyBitsString = bits.substring(0, entropyBits) - val checksumBitsString = bits.substring(startIndex = entropyBits, endIndex = entropyBits + checksumBits) - - val entropyBytes = ByteArray(entropyBits / 8) - for (i in 0 until entropyBits / 8) { - val byte = entropyBitsString.substring(startIndex = i * 8, endIndex = (i + 1) * 8).toInt(2) - entropyBytes[i] = byte.toByte() - } - - val sha256 = MessageDigest.getInstance("SHA-256") - val hash = sha256.digest(entropyBytes) - - val derivedChecksumBits = hash[0].toInt().and(0xFF).toString(2) - .padStart(8, '0') - .substring(0, checksumBits) - - return checksumBitsString == derivedChecksumBits -} diff --git a/app/src/main/java/to/bitkit/utils/Logger.kt b/app/src/main/java/to/bitkit/utils/Logger.kt index 2fe2f143f..ade0299aa 100644 --- a/app/src/main/java/to/bitkit/utils/Logger.kt +++ b/app/src/main/java/to/bitkit/utils/Logger.kt @@ -28,22 +28,45 @@ private const val COMPACT = false enum class LogSource { Ldk, Bitkit, Unknown } enum class LogLevel { PERF, VERBOSE, GOSSIP, TRACE, DEBUG, INFO, WARN, ERROR; } -object Logger { - private val delegate by lazy { LoggerImpl(APP, saver = LogSaverImpl(buildSessionLogFilePath(LogSource.Bitkit))) } +val Logger = AppLogger() + +class AppLogger( + private val source: LogSource = LogSource.Bitkit, +) { + private var delegate: LoggerImpl? = null + + init { + delegate = runCatching { createDelegate() }.getOrNull() + } + + private fun createDelegate(): LoggerImpl { + val sessionPath = runCatching { buildSessionLogFilePath(source) }.getOrElse { "" } + return LoggerImpl(APP, LogSaverImpl(source, sessionPath)) + } + + fun reset() { + warn("Wiping entire logs directory...") + runCatching { Env.logDir.deleteRecursively() } + delegate = runCatching { createDelegate() }.getOrNull() + } fun info( msg: String?, context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.info(msg, context, file, line) + ) { + delegate?.info(msg, context, file, line) + } fun debug( msg: String?, context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.debug(msg, context, file, line) + ) { + delegate?.debug(msg, context, file, line) + } fun warn( msg: String?, @@ -51,7 +74,9 @@ object Logger { context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.warn(msg, e, context, file, line) + ) { + delegate?.warn(msg, e, context, file, line) + } fun error( msg: String?, @@ -59,7 +84,9 @@ object Logger { context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.error(msg, e, context, file, line) + ) { + delegate?.error(msg, e, context, file, line) + } fun verbose( msg: String?, @@ -67,14 +94,18 @@ object Logger { context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.verbose(msg, e, context, file, line) + ) { + delegate?.verbose(msg, e, context, file, line) + } fun performance( msg: String?, context: String = "", file: String = getCallerPath(), line: Int = getCallerLine(), - ) = delegate.performance(msg, context, file, line) + ) { + delegate?.performance(msg, context, file, line) + } } class LoggerImpl( @@ -160,15 +191,22 @@ interface LogSaver { fun save(message: String) } -class LogSaverImpl(private val sessionFilePath: String) : LogSaver { +class LogSaverImpl( + source: LogSource, + private val sessionFilePath: String, +) : LogSaver { private val queue: CoroutineScope by lazy { CoroutineScope(newSingleThreadDispatcher(ServiceQueue.LOG.name) + SupervisorJob()) } init { - // Clean all old log files in background - CoroutineScope(Dispatchers.IO).launch { - cleanupOldLogFiles() + if (sessionFilePath.isNotEmpty()) { + log("Log session for '${source.name}' initialized with file path: '$sessionFilePath'") + + // Clean all old log files in background + CoroutineScope(Dispatchers.IO).launch { + cleanupOldLogFiles() + } } } @@ -186,10 +224,15 @@ class LogSaverImpl(private val sessionFilePath: String) : LogSaver { } } + private fun log(message: String, level: LogLevel = LogLevel.INFO) { + val formatted = formatLog(level, message, TAG, getCallerPath(), getCallerLine()) + Log.i(APP, formatted) + save(formatted) + } + private fun cleanupOldLogFiles(maxTotalSizeMB: Int = 20) { - Log.v(APP, "Deleting old log files…") - val logDir = runCatching { File(Env.logDir) }.getOrElse { return } - if (!logDir.exists()) return + log("Deleting old log files…", LogLevel.VERBOSE) + val logDir = runCatching { Env.logDir }.getOrNull() ?: return val logFiles = logDir .listFiles { file -> file.extension == "log" } @@ -216,11 +259,16 @@ class LogSaverImpl(private val sessionFilePath: String) : LogSaver { } Log.v(APP, "Deleted all old log files.") } + + companion object { + private const val TAG = "LogSaver" + } } class LdkLogWriter( private val maxLogLevel: LdkLogLevel = Env.ldkLogLevel, - saver: LogSaver = LogSaverImpl(buildSessionLogFilePath(LogSource.Ldk)), + private val source: LogSource = LogSource.Ldk, + saver: LogSaver = LogSaverImpl(source, buildSessionLogFilePath(source)), ) : LogWriter { private val delegate: LoggerImpl = LoggerImpl(LDK, saver) @@ -243,14 +291,11 @@ class LdkLogWriter( } private fun buildSessionLogFilePath(source: LogSource): String { - val logDir = runCatching { File(Env.logDir) }.getOrElse { return "" } - if (!logDir.exists()) return "" - + val logDir = Env.logDir val sourceName = source.name.lowercase() val timestamp = utcDateFormatterOf(DatePattern.LOG_FILE).format(Date()) - val sessionLogFilePath = logDir.resolve("${sourceName}_$timestamp.log").path - Log.i(APP, "Log session for '$sourceName' initialized with file path: '$sessionLogFilePath'") - return sessionLogFilePath + val path = logDir.resolve("${sourceName}_$timestamp.log").path + return path } private fun formatLog(level: LogLevel, msg: String?, context: String, path: String, line: Int): String { diff --git a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt index ccdc74a2a..bebd66a76 100644 --- a/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/ActivityListViewModel.kt @@ -8,15 +8,20 @@ import com.synonym.bitkitcore.PaymentType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import to.bitkit.di.BgDispatcher +import to.bitkit.ext.isTransfer import to.bitkit.repositories.ActivityRepo -import to.bitkit.services.CoreService import to.bitkit.services.LdkNodeEventBus import to.bitkit.ui.screens.wallets.activity.components.ActivityTab import to.bitkit.utils.Logger @@ -25,7 +30,6 @@ import javax.inject.Inject @HiltViewModel class ActivityListViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, - private val coreService: CoreService, private val ldkNodeEventBus: LdkNodeEventBus, private val activityRepo: ActivityRepo, ) : ViewModel() { @@ -38,201 +42,135 @@ class ActivityListViewModel @Inject constructor( private val _onchainActivities = MutableStateFlow?>(null) val onchainActivities = _onchainActivities.asStateFlow() - private val _searchText = MutableStateFlow("") - val searchText = _searchText.asStateFlow() - - fun setSearchText(text: String) { - _searchText.value = text - } - - private val _startDate = MutableStateFlow(null) - val startDate = _startDate.asStateFlow() - - private val _endDate = MutableStateFlow(null) - val endDate = _endDate.asStateFlow() - - private val _selectedTags = MutableStateFlow>(emptySet()) - val selectedTags = _selectedTags.asStateFlow() - - fun toggleTag(tag: String) { - _selectedTags.value = if (_selectedTags.value.contains(tag)) { - _selectedTags.value - tag - } else { - _selectedTags.value + tag - } - } - private val _latestActivities = MutableStateFlow?>(null) val latestActivities = _latestActivities.asStateFlow() - private val _availableTags = MutableStateFlow>(emptyList()) - val availableTags = _availableTags.asStateFlow() + val availableTags: StateFlow> = activityRepo.state.map { it.tags }.stateInScope(emptyList()) - private var isClearingFilters = false + private val _filters = MutableStateFlow(ActivityFilters()) - private val _selectedTab = MutableStateFlow(ActivityTab.ALL) - val selectedTab = _selectedTab.asStateFlow() + // individual filters for UI + val searchText: StateFlow = _filters.map { it.searchText }.stateInScope("") + val startDate: StateFlow = _filters.map { it.startDate }.stateInScope(null) + val endDate: StateFlow = _filters.map { it.endDate }.stateInScope(null) + val selectedTags: StateFlow> = _filters.map { it.tags }.stateInScope(emptySet()) + val selectedTab: StateFlow = _filters.map { it.tab }.stateInScope(ActivityTab.ALL) - fun setTab(tab: ActivityTab) { - _selectedTab.value = tab - viewModelScope.launch(bgDispatcher) { - updateFilteredActivities() - } + fun setSearchText(text: String) = _filters.update { it.copy(searchText = text) } + + fun setTab(tab: ActivityTab) = _filters.update { it.copy(tab = tab) } + + fun toggleTag(tag: String) = _filters.update { + val newTags = if (tag in it.tags) it.tags - tag else it.tags + tag + it.copy(tags = newTags) } init { - viewModelScope.launch(bgDispatcher) { - ldkNodeEventBus.events.collect { - // TODO: sync only on specific events for better performance - syncLdkNodePayments() - } - } - - observeSearchText() - observeDateRange() - observeSelectedTags() + observeActivities() + observeFilters() + observerNodeEvents() + resync() + } - fetchLatestActivities() + fun resync() = viewModelScope.launch { + activityRepo.syncActivities() } - @OptIn(FlowPreview::class) - private fun observeSearchText() { - viewModelScope.launch(bgDispatcher) { - _searchText - .debounce(300) - .collect { - if (!isClearingFilters) { - updateFilteredActivities() - } - } + private fun observeActivities() = viewModelScope.launch { + activityRepo.activitiesChanged.collect { + refreshActivityState() } } - private fun observeDateRange() { - viewModelScope.launch(bgDispatcher) { - combine(_startDate, _endDate) { _, _ -> } - .collect { - if (!isClearingFilters) { - updateFilteredActivities() - } - } - } + private fun observeFilters() = viewModelScope.launch { + @OptIn(FlowPreview::class) + combine( + _filters.map { it.searchText }.debounce(300), + _filters.map { it.copy(searchText = "") }, + activityRepo.activitiesChanged, + ) { debouncedSearch, filtersWithoutSearch, _ -> + fetchFilteredActivities(filtersWithoutSearch.copy(searchText = debouncedSearch)) + }.collect { _filteredActivities.value = it } } - private fun observeSelectedTags() { - viewModelScope.launch(bgDispatcher) { - _selectedTags.collect { - if (!isClearingFilters) { - updateFilteredActivities() - } - } + private fun observerNodeEvents() = viewModelScope.launch { + ldkNodeEventBus.events.collect { + // TODO: resync only on specific events for better performance + resync() } } - fun fetchLatestActivities() { - viewModelScope.launch(bgDispatcher) { - try { - // Fetch latest activities for the home screen - val limitLatest = 3u - _latestActivities.value = coreService.activity.get(filter = ActivityFilter.ALL, limit = limitLatest) - - // Fetch lightning and onchain activities - _lightningActivities.value = coreService.activity.get(filter = ActivityFilter.LIGHTNING) - _onchainActivities.value = coreService.activity.get(filter = ActivityFilter.ONCHAIN) - - // Fetch filtered activities and available tags - updateFilteredActivities() - updateAvailableTags() - } catch (e: Exception) { - Logger.error("Failed to sync activities", e) - } - } + private suspend fun refreshActivityState() { + val all = activityRepo.getActivities(filter = ActivityFilter.ALL).getOrNull() ?: emptyList() + _latestActivities.value = all.take(SIZE_LATEST) + _lightningActivities.value = all.filter { it is Activity.Lightning } + _onchainActivities.value = all.filter { it is Activity.Onchain } } - private suspend fun updateFilteredActivities() = withContext(bgDispatcher) { - try { - var txType: PaymentType? = when (_selectedTab.value) { - ActivityTab.SENT -> PaymentType.SENT - ActivityTab.RECEIVED -> PaymentType.RECEIVED - else -> null - } - - val activities = coreService.activity.get( - filter = ActivityFilter.ALL, - txType = txType, - tags = _selectedTags.value.takeIf { it.isNotEmpty() }?.toList(), - search = _searchText.value.takeIf { it.isNotEmpty() }, - minDate = _startDate.value?.let { it / 1000 }?.toULong(), - maxDate = _endDate.value?.let { it / 1000 }?.toULong(), - ) - - _filteredActivities.value = when (_selectedTab.value) { - ActivityTab.OTHER -> activities.filter { it is Activity.Onchain && it.v1.isTransfer } - else -> activities - } - } catch (e: Exception) { + private suspend fun fetchFilteredActivities(filters: ActivityFilters): List? { + val txType = when (filters.tab) { + ActivityTab.SENT -> PaymentType.SENT + ActivityTab.RECEIVED -> PaymentType.RECEIVED + else -> null + } + + val activities = activityRepo.getActivities( + filter = ActivityFilter.ALL, + txType = txType, + tags = filters.tags.takeIf { it.isNotEmpty() }?.toList(), + search = filters.searchText.takeIf { it.isNotEmpty() }, + minDate = filters.startDate?.let { it / 1000 }?.toULong(), + maxDate = filters.endDate?.let { it / 1000 }?.toULong(), + ).getOrElse { e -> Logger.error("Failed to filter activities", e) + return null + } + + return when (filters.tab) { + ActivityTab.OTHER -> activities.filter { it.isTransfer() } + else -> activities } } fun updateAvailableTags() { - viewModelScope.launch(bgDispatcher) { - try { - _availableTags.value = coreService.activity.allPossibleTags() - } catch (e: Exception) { - Logger.error("Failed to get available tags", e) - _availableTags.value = emptyList() - } + viewModelScope.launch { + activityRepo.getAllAvailableTags() } } - fun setDateRange(startDate: Long?, endDate: Long?) { - _startDate.value = startDate - _endDate.value = endDate + fun setDateRange(startDate: Long?, endDate: Long?) = _filters.update { + it.copy(startDate = startDate, endDate = endDate) } - fun clearDateRange() { - _startDate.value = null - _endDate.value = null + fun clearDateRange() = _filters.update { + it.copy(startDate = null, endDate = null) } - fun clearFilters() { - viewModelScope.launch(bgDispatcher) { - try { - isClearingFilters = true - - _searchText.value = "" - _selectedTags.value = emptySet() - _startDate.value = null - _endDate.value = null - _selectedTab.value = ActivityTab.ALL - - updateFilteredActivities() - } finally { - isClearingFilters = false - } - } - } + fun clearFilters() = _filters.update { ActivityFilters() } - fun syncLdkNodePayments() { - viewModelScope.launch { - activityRepo.syncActivities().onSuccess { - fetchLatestActivities() - } - } + fun generateRandomTestData(count: Int) = viewModelScope.launch(bgDispatcher) { + activityRepo.generateTestData(count) } - fun generateRandomTestData(count: Int) { - viewModelScope.launch(bgDispatcher) { - coreService.activity.generateRandomTestData(count) - fetchLatestActivities() - } + fun removeAllActivities() = viewModelScope.launch(bgDispatcher) { + activityRepo.removeAllActivities() } - fun removeAllActivities() { - viewModelScope.launch(bgDispatcher) { - coreService.activity.removeAll() - fetchLatestActivities() - } + private fun Flow.stateInScope( + initialValue: T, + started: SharingStarted = SharingStarted.WhileSubscribed(MS_TIMEOUT_SUB), + ): StateFlow = stateIn(viewModelScope, started, initialValue) + + companion object { + private const val SIZE_LATEST = 3 + private const val MS_TIMEOUT_SUB = 5000L } } + +data class ActivityFilters( + val tab: ActivityTab = ActivityTab.ALL, + val tags: Set = emptySet(), + val searchText: String = "", + val startDate: Long? = null, + val endDate: Long? = null, +) diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 67e29d10d..5cd9f303c 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first @@ -1303,8 +1304,8 @@ class AppViewModel @Inject constructor( var isNewTransactionSheetEnabled = true private set - var showNewTransaction by mutableStateOf(false) - private set + private val _showNewTransaction = MutableStateFlow(false) + val showNewTransaction: StateFlow = _showNewTransaction.asStateFlow() private val _newTransaction = MutableStateFlow( NewTransactionSheetDetails( @@ -1336,6 +1337,8 @@ class AppViewModel @Inject constructor( details: NewTransactionSheetDetails, event: Event?, ) = viewModelScope.launch { + if (backupRepo.isRestoring.value) return@launch + if (!isNewTransactionSheetEnabled) { Logger.debug("NewTransactionSheet display blocked by isNewTransactionSheetEnabled=false", context = TAG) return@launch @@ -1346,9 +1349,10 @@ class AppViewModel @Inject constructor( paymentHashOrTxId = event.paymentHash, type = ActivityFilter.ALL, txType = PaymentType.RECEIVED, - retry = false + retry = false, ).getOrNull() + // TODO check if this is still needed now that we're disabling the sheet during restore // TODO Temporary fix while ldk-node bug is not fixed https://github.com/synonymdev/bitkit-android/pull/297 if (activity != null) { Logger.warn("Activity ${activity.rawId()} already exists, skipping sheet", context = TAG) @@ -1359,11 +1363,11 @@ class AppViewModel @Inject constructor( hideSheet() _newTransaction.update { details } - showNewTransaction = true + _showNewTransaction.value = true } fun hideNewTransactionSheet() { - showNewTransaction = false + _showNewTransaction.value = false } // endregion @@ -1575,6 +1579,8 @@ class AppViewModel @Inject constructor( } fun checkTimedSheets() { + if (backupRepo.isRestoring.value) return + if (currentTimedSheet != null || timedSheetQueue.isNotEmpty()) { Logger.debug("Timed sheet already active, skipping check") return diff --git a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt index bad26b050..17f07bad6 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LogsViewModel.kt @@ -23,7 +23,7 @@ import javax.inject.Inject @HiltViewModel class LogsViewModel @Inject constructor( private val application: Application, - private val logsRepo: LogsRepo + private val logsRepo: LogsRepo, ) : AndroidViewModel(application) { private val _logs = MutableStateFlow>(emptyList()) val logs: StateFlow> = _logs.asStateFlow() @@ -33,12 +33,8 @@ class LogsViewModel @Inject constructor( fun loadLogs() { viewModelScope.launch { - logsRepo.getLogs() - .onSuccess { logList -> - _logs.update { logList } - }.onFailure { e -> - _logs.update { emptyList() } - } + val logFiles = logsRepo.getLogs().getOrDefault(emptyList()) + _logs.update { logFiles } } } @@ -83,17 +79,8 @@ class LogsViewModel @Inject constructor( fun deleteAllLogs() { viewModelScope.launch { - try { - val logDir = runCatching { File(Env.logDir) }.getOrElse { return@launch } - logDir.listFiles { file -> - file.extension == "log" - }?.forEach { file -> - file.delete() - } - loadLogs() - } catch (e: Exception) { - Logger.error("Failed to delete logs", e) - } + Logger.reset() + loadLogs() } } } diff --git a/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt new file mode 100644 index 000000000..b45b58bdb --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/RestoreWalletViewModel.kt @@ -0,0 +1,207 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.services.core.Bip39Service +import javax.inject.Inject + +private const val WORDS_MIN = 12 +private const val WORDS_MAX = 24 + +@HiltViewModel +class RestoreWalletViewModel @Inject constructor( + private val bip39Service: Bip39Service, +) : ViewModel() { + private val _uiState = MutableStateFlow(RestoreWalletUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + _uiState.update { it.copy(focusedIndex = 0) } + recomputeValidationState() + } + + private fun recomputeValidationState() = viewModelScope.launch { + val currentState = _uiState.value + val checksumError = currentState.isChecksumErrorVisible() + val buttonsEnabled = currentState.areButtonsEnabled() + + _uiState.update { + it.copy( + checksumErrorVisible = checksumError, + areButtonsEnabled = buttonsEnabled + ) + } + } + + fun onChangeWord(index: Int, value: String) { + if (value.contains(Regex("\\s"))) { + handlePastedWords(value) + } else { + updateWordValidity(index, value) + updateSuggestions(value, _uiState.value.focusedIndex) + _uiState.update { it.copy(scrollToFieldIndex = index) } + } + } + + fun onChangeWordFocus(index: Int, focused: Boolean) { + if (focused) { + _uiState.update { + it.copy( + focusedIndex = index, + scrollToFieldIndex = index + ) + } + updateSuggestions(_uiState.value.words[index], index) + } else if (_uiState.value.focusedIndex == index) { + _uiState.update { + it.copy( + focusedIndex = null, + suggestions = emptyList() + ) + } + } + } + + fun onSelectSuggestion(suggestion: String) { + _uiState.value.focusedIndex?.let { index -> + updateWordValidity(index, suggestion) + _uiState.update { it.copy(suggestions = emptyList()) } + } + } + + fun onAdvancedClick() { + _uiState.update { + it.copy( + showingPassphrase = !it.showingPassphrase, + bip39Passphrase = "" + ) + } + } + + fun onChangePassphrase(passphrase: String) = _uiState.update { it.copy(bip39Passphrase = passphrase) } + + fun onBackspaceInEmpty(index: Int) { + if (index > 0) { + _uiState.update { it.copy(focusedIndex = index - 1) } + } + } + + fun onKeyboardDismiss() = _uiState.update { it.copy(shouldDismissKeyboard = false) } + + fun onScrollComplete() = _uiState.update { it.copy(scrollToFieldIndex = null) } + + private fun handlePastedWords(pastedText: String) = viewModelScope.launch { + val separators = Regex("\\s+") // any whitespace chars to account for different sources like password managers + val pastedWords = pastedText + .split(separators) + .filter { it.isNotBlank() } + if (pastedWords.size == WORDS_MIN || pastedWords.size == WORDS_MAX) { + val invalidIndices = pastedWords.withIndex() + .filter { !bip39Service.isValidWord(it.value) } + .map { it.index } + .toSet() + + val newWords = _uiState.value.words.toMutableList().apply { + pastedWords.forEachIndexed { index, word -> this[index] = word } + for (index in pastedWords.size until WORDS_MAX) { + this[index] = "" + } + } + + _uiState.update { + it.copy( + words = newWords, + invalidWordIndices = invalidIndices, + is24Words = pastedWords.size == WORDS_MAX, + shouldDismissKeyboard = invalidIndices.isEmpty(), + focusedIndex = null, + suggestions = emptyList(), + ) + } + recomputeValidationState() + } + } + + private fun updateWordValidity(index: Int, value: String) = viewModelScope.launch { + val newWords = _uiState.value.words.toMutableList().apply { + this[index] = value + } + + val newInvalidIndices = _uiState.value.invalidWordIndices.toMutableSet() + if (!bip39Service.isValidWord(value) && value.isNotEmpty()) { + newInvalidIndices.add(index) + } else { + newInvalidIndices.remove(index) + } + + _uiState.update { + it.copy( + words = newWords, + invalidWordIndices = newInvalidIndices, + ) + } + recomputeValidationState() + } + + private fun updateSuggestions(input: String, index: Int?) = viewModelScope.launch { + if (index == null || input.length < 2) { + _uiState.update { it.copy(suggestions = emptyList()) } + return@launch + } + + val suggestions = bip39Service.getSuggestions(input.lowercase(), 3u) + val filtered = if (suggestions.size == 1 && suggestions.firstOrNull() == input) { + emptyList() + } else { + suggestions + } + + _uiState.update { it.copy(suggestions = filtered) } + } + + private suspend fun RestoreWalletUiState.areButtonsEnabled(): Boolean { + val activeWords = words.subList(0, wordCount) + return activeWords.none { it.isBlank() } && + invalidWordIndices.isEmpty() && + !isChecksumErrorVisible() + } + + private suspend fun RestoreWalletUiState.isChecksumErrorVisible(): Boolean { + val activeWords = words.subList(0, wordCount) + return activeWords.none { it.isBlank() } && + invalidWordIndices.isEmpty() && + !validateBip39Checksum(activeWords) + } + + private suspend fun validateBip39Checksum(wordList: List): Boolean { + if (!bip39Service.isValidMnemonicSize(wordList)) return false + if (wordList.any { !bip39Service.isValidWord(it) }) return false + + return bip39Service.validateMnemonic(wordList.joinToString(" ")).isSuccess + } +} + +data class RestoreWalletUiState( + val words: List = List(WORDS_MAX) { "" }, + val invalidWordIndices: Set = emptySet(), + val suggestions: List = emptyList(), + val focusedIndex: Int? = null, + val bip39Passphrase: String = "", + val showingPassphrase: Boolean = false, + val is24Words: Boolean = false, + val shouldDismissKeyboard: Boolean = false, + val scrollToFieldIndex: Int? = null, + val checksumErrorVisible: Boolean = false, + val areButtonsEnabled: Boolean = false, +) { + val wordCount: Int get() = if (is24Words) WORDS_MAX else WORDS_MIN + val wordsPerColumn: Int get() = if (is24Words) WORDS_MIN else 6 + + val bip39Mnemonic: String get() = words.subList(0, wordCount).joinToString(" ").trim() +} diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 564f47d73..1f5a3e03d 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -118,10 +118,6 @@ class WalletViewModel @Inject constructor( restoreState = RestoreState.BackupRestoreCompleted } - fun setRestoringWalletState() { - restoreState = RestoreState.RestoringWallet - } - fun onRestoreContinue() { restoreState = RestoreState.NotRestoring } @@ -136,9 +132,7 @@ class WalletViewModel @Inject constructor( } } - fun setInitNodeLifecycleState() { - lightningRepo.setInitNodeLifecycleState() - } + fun setInitNodeLifecycleState() = lightningRepo.setInitNodeLifecycleState() fun start(walletIndex: Int = 0) { if (!walletExists) return @@ -245,6 +239,7 @@ class WalletViewModel @Inject constructor( } suspend fun createWallet(bip39Passphrase: String?) { + setInitNodeLifecycleState() walletRepo.createWallet(bip39Passphrase) .onSuccess { backupRepo.scheduleFullBackup() @@ -255,9 +250,12 @@ class WalletViewModel @Inject constructor( } suspend fun restoreWallet(mnemonic: String, bip39Passphrase: String?) { + setInitNodeLifecycleState() + restoreState = RestoreState.RestoringWallet + walletRepo.restoreWallet( mnemonic = mnemonic, - bip39Passphrase = bip39Passphrase + bip39Passphrase = bip39Passphrase, ).onFailure { error -> ToastEventBus.send(error) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0752b9d8..f1f09c921 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ No Back Learn More + Never Understood Connect Min @@ -625,6 +626,7 @@ Data Backup Failure Bitkit failed to back up wallet data. Retrying in {interval, plural, one {# minute} other {# minutes}}. latest data backups + Running Failed Backup: {time} Latest Backup: {time} Connections diff --git a/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt new file mode 100644 index 000000000..3fcd5408e --- /dev/null +++ b/app/src/test/java/to/bitkit/ext/DateTimeExtTest.kt @@ -0,0 +1,140 @@ +package to.bitkit.ext + +import org.junit.Test +import to.bitkit.env.Env +import to.bitkit.test.BaseUnitTest +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DateTimeExtTest : BaseUnitTest() { + + @Test + fun `toRelativeTimeString returns now for very recent timestamps`() { + val now = System.currentTimeMillis() + val result = now.toRelativeTimeString() + // May return "now" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns minutes ago for recent timestamps`() { + val fiveMinutesAgo = System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5) + val result = fiveMinutesAgo.toRelativeTimeString() + // May return relative "minute" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns hours ago for timestamps within a day`() { + val twoHoursAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(2) + val result = twoHoursAgo.toRelativeTimeString() + // May return relative "hour" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns yesterday for one day ago`() { + val oneDayAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1) + val result = oneDayAgo.toRelativeTimeString() + // May return relative "yesterday"/"day" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns days ago for multiple days`() { + val threeDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(3) + val result = threeDaysAgo.toRelativeTimeString() + // May return relative "day" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns weeks ago for multiple weeks`() { + val twoWeeksAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(14) + val result = twoWeeksAgo.toRelativeTimeString() + // May return relative "week" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns months ago for multiple months`() { + val twoMonthsAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(60) + val result = twoMonthsAgo.toRelativeTimeString() + // May return relative "month" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString returns years ago for multiple years`() { + val twoYearsAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(730) + val result = twoYearsAgo.toRelativeTimeString() + // May return relative "year" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString handles future timestamps gracefully`() { + val future = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1) + val result = future.toRelativeTimeString() + // May return relative "in" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString supports all configured locales`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + + Env.locales.forEach { languageTag -> + val locale = Locale.forLanguageTag(languageTag) + val result = twoDaysAgo.toRelativeTimeString(locale) + + assertNotNull(result, "Locale $languageTag returned null") + assertTrue(result.isNotEmpty(), "Locale $languageTag returned empty string") + } + } + + @Test + fun `toRelativeTimeString with explicit English locale produces expected output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.ENGLISH) + + // May return relative "day" or absolute timestamp as fallback + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString with explicit German locale produces non-empty output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.GERMAN) + + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString with explicit French locale produces non-empty output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.FRENCH) + + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString with explicit Italian locale produces non-empty output`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val result = twoDaysAgo.toRelativeTimeString(Locale.ITALIAN) + + assertTrue(result.isNotEmpty()) + } + + @Test + fun `toRelativeTimeString preserves backward compatibility with default locale`() { + val twoDaysAgo = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2) + val resultWithoutParam = twoDaysAgo.toRelativeTimeString() + val resultWithDefaultParam = twoDaysAgo.toRelativeTimeString(Locale.getDefault()) + + assertEquals(resultWithDefaultParam, resultWithoutParam) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt index c1612aad4..03fe06b12 100644 --- a/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/ActivityRepoTest.kt @@ -12,7 +12,6 @@ import org.junit.Test import org.lightningdevkit.ldknode.PaymentDetails import org.mockito.kotlin.any import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -32,12 +31,13 @@ import kotlin.test.assertTrue class ActivityRepoTest : BaseUnitTest() { - private val coreService: CoreService = mock() - private val lightningRepo: LightningRepo = mock() - private val cacheStore: CacheStore = mock() - private val addressChecker: AddressChecker = mock() - private val db: AppDb = mock() - private val clock: Clock = mock() + private val coreService = mock() + private val lightningRepo = mock() + private val transferRepo = mock() + private val cacheStore = mock() + private val addressChecker = mock() + private val db = mock() + private val clock = mock() private lateinit var sut: ActivityRepo @@ -66,7 +66,7 @@ class ActivityRepoTest : BaseUnitTest() { cacheStore = cacheStore, addressChecker = addressChecker, db = db, - transferRepo = mock(), + transferRepo = transferRepo, clock = clock, ) } @@ -76,13 +76,15 @@ class ActivityRepoTest : BaseUnitTest() { val payments = listOf(testPaymentDetails) wheneverBlocking { lightningRepo.getPayments() }.thenReturn(Result.success(payments)) wheneverBlocking { coreService.activity.getActivity(any()) }.thenReturn(null) - wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(any(), eq(false)) }.thenReturn(Unit) + wheneverBlocking { coreService.activity.syncLdkNodePaymentsToActivities(payments) }.thenReturn(Unit) + wheneverBlocking { transferRepo.syncTransferStates() }.thenReturn(Result.success(Unit)) + wheneverBlocking { coreService.activity.allPossibleTags() }.thenReturn(emptyList()) val result = sut.syncActivities() assertTrue(result.isSuccess) verify(lightningRepo).getPayments() - verify(coreService.activity).syncLdkNodePaymentsToActivities(payments, forceUpdate = false) + verify(coreService.activity).syncLdkNodePaymentsToActivities(payments) assertFalse(sut.isSyncingLdkNodePayments.value) } diff --git a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt index cf4375ddf..654987146 100644 --- a/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/WalletRepoTest.kt @@ -20,13 +20,13 @@ import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore -import to.bitkit.data.backup.VssStoreIdProvider import to.bitkit.data.keychain.Keychain import to.bitkit.models.BalanceState import to.bitkit.services.CoreService import to.bitkit.services.OnchainService import to.bitkit.test.BaseUnitTest import to.bitkit.usecases.DeriveBalanceStateUseCase +import to.bitkit.usecases.WipeWalletUseCase import to.bitkit.utils.AddressChecker import to.bitkit.utils.AddressInfo import to.bitkit.utils.AddressStats @@ -38,17 +38,16 @@ class WalletRepoTest : BaseUnitTest() { private lateinit var sut: WalletRepo - private val db: AppDb = mock() - private val keychain: Keychain = mock() - private val coreService: CoreService = mock() - private val onchainService: OnchainService = mock() - private val settingsStore: SettingsStore = mock() - private val addressChecker: AddressChecker = mock() - private val lightningRepo: LightningRepo = mock() - private val cacheStore: CacheStore = mock() - private val deriveBalanceStateUseCase: DeriveBalanceStateUseCase = mock() - private val vssStoreIdProvider = mock() - private val backupRepo = mock() + private val db = mock() + private val keychain = mock() + private val coreService = mock() + private val onchainService = mock() + private val settingsStore = mock() + private val addressChecker = mock() + private val lightningRepo = mock() + private val cacheStore = mock() + private val deriveBalanceStateUseCase = mock() + private val wipeWalletUseCase = mock() @Before fun setUp() { @@ -78,8 +77,7 @@ class WalletRepoTest : BaseUnitTest() { lightningRepo = lightningRepo, cacheStore = cacheStore, deriveBalanceStateUseCase = deriveBalanceStateUseCase, - vssStoreIdProvider = vssStoreIdProvider, - backupRepo = backupRepo, + wipeWalletUseCase = wipeWalletUseCase, ) @Test @@ -608,6 +606,26 @@ class WalletRepoTest : BaseUnitTest() { verify(lightningRepo, never()).newAddress() } + + @Test + fun `wipeWallet should call use case`() = test { + wheneverBlocking { wipeWalletUseCase.invoke(any(), any(), any()) }.thenReturn(Result.success(Unit)) + + val result = sut.wipeWallet() + + assertTrue(result.isSuccess) + verify(wipeWalletUseCase).invoke(any(), any(), any()) + } + + @Test + fun `wipeWallet should return failure when use case fails`() = test { + val error = RuntimeException("Reset failed") + wheneverBlocking { wipeWalletUseCase.invoke(any(), any(), any()) }.thenReturn(Result.failure(error)) + + val result = sut.wipeWallet() + + assertTrue(result.isFailure) + } } private fun mockAddressInfo() = AddressInfo( diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index 0b5e30c14..068f3bb3d 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -82,57 +82,58 @@ class WalletViewModelTest : BaseUnitTest() { } @Test - fun `disconnectPeer should call lightningRepo disconnectPeer and send success toast`() = test { + fun `disconnectPeer should call lightningRepo disconnectPeer`() = test { val testPeer = PeerDetails.from("nodeId", "host", "9735") - whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.success(Unit)) + val testError = Exception("Test error") + whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.failure(testError)) sut.disconnectPeer(testPeer) verify(lightningRepo).disconnectPeer(testPeer) - // Add verification for ToastEventBus.send if you have a way to capture those events } @Test - fun `disconnectPeer should call lightningRepo disconnectPeer and send failure toast`() = test { - val testPeer = PeerDetails.from("nodeId", "host", "9735") - val testError = Exception("Test error") - whenever(lightningRepo.disconnectPeer(testPeer)).thenReturn(Result.failure(testError)) - - sut.disconnectPeer(testPeer) + fun `wipeWallet should call walletRepo wipeWallet`() = test { + whenever(walletRepo.wipeWallet(walletIndex = 0)).thenReturn(Result.success(Unit)) + sut.wipeWallet() - verify(lightningRepo).disconnectPeer(testPeer) - // Add verification for ToastEventBus.send if you have a way to capture those events + verify(walletRepo).wipeWallet(walletIndex = 0) } @Test - fun `wipeWallet should call walletRepo wipeWallet`() = - test { - whenever(walletRepo.wipeWallet(walletIndex = 0)).thenReturn(Result.success(Unit)) - sut.wipeWallet() + fun `createWallet should call walletRepo createWallet`() = test { + whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.success(Unit)) + + sut.createWallet(null) - verify(walletRepo).wipeWallet(walletIndex = 0) - } + verify(walletRepo).createWallet(anyOrNull()) + } @Test - fun `createWallet should call walletRepo createWallet and send failure toast`() = test { - val testError = Exception("Test error") - whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.failure(testError)) + fun `createWallet should call setInitNodeLifecycleState`() = test { + whenever(walletRepo.createWallet(anyOrNull())).thenReturn(Result.success(Unit)) sut.createWallet(null) - verify(walletRepo).createWallet(anyOrNull()) - // Add verification for ToastEventBus.send + verify(lightningRepo).setInitNodeLifecycleState() } @Test - fun `restoreWallet should call walletRepo restoreWallet and send failure toast`() = test { - val testError = Exception("Test error") - whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.failure(testError)) + fun `restoreWallet should call walletRepo restoreWallet`() = test { + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) sut.restoreWallet("test_mnemonic", null) verify(walletRepo).restoreWallet(any(), anyOrNull()) - // Add verification for ToastEventBus.send + } + + @Test + fun `restoreWallet should call setInitNodeLifecycleState`() = test { + whenever(walletRepo.restoreWallet(any(), anyOrNull())).thenReturn(Result.success(Unit)) + + sut.restoreWallet("test_mnemonic", null) + + verify(lightningRepo).setInitNodeLifecycleState() } @Test @@ -169,7 +170,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `onBackupRestoreSuccess should reset restoreState`() = test { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) mockWalletState.value = mockWalletState.value.copy(walletExists = true) - sut.setRestoringWalletState() + sut.restoreWallet("mnemonic", "passphrase") assertEquals(RestoreState.RestoringWallet, sut.restoreState) sut.onRestoreContinue() @@ -182,7 +183,7 @@ class WalletViewModelTest : BaseUnitTest() { fun `proceedWithoutRestore should exit restore flow`() = test { val testError = Exception("Test error") whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.failure(testError)) - sut.setRestoringWalletState() + sut.restoreWallet("mnemonic", "passphrase") mockWalletState.value = mockWalletState.value.copy(walletExists = true) assertEquals(RestoreState.BackupRestoreCompleted, sut.restoreState) @@ -196,7 +197,7 @@ class WalletViewModelTest : BaseUnitTest() { whenever(backupRepo.performFullRestoreFromLatestBackup()).thenReturn(Result.success(Unit)) assertEquals(RestoreState.NotRestoring, sut.restoreState) - sut.setRestoringWalletState() + sut.restoreWallet("mnemonic", "passphrase") assertEquals(RestoreState.RestoringWallet, sut.restoreState) mockWalletState.value = mockWalletState.value.copy(walletExists = true) diff --git a/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt new file mode 100644 index 000000000..cb6c8dfab --- /dev/null +++ b/app/src/test/java/to/bitkit/usecases/WipeWalletUseCaseTest.kt @@ -0,0 +1,153 @@ +package to.bitkit.usecases + +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking +import to.bitkit.data.AppDb +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsStore +import to.bitkit.data.keychain.Keychain +import to.bitkit.repositories.ActivityRepo +import to.bitkit.repositories.BackupRepo +import to.bitkit.repositories.BlocktankRepo +import to.bitkit.repositories.LightningRepo +import to.bitkit.services.CoreService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertTrue + +class WipeWalletUseCaseTest : BaseUnitTest() { + + private val backupRepo = mock() + private val keychain = mock() + private val coreService = mock() + private val db = mock() + private val settingsStore = mock() + private val cacheStore = mock() + private val widgetsStore = mock() + private val blocktankRepo = mock() + private val activityRepo = mock() + private val lightningRepo = mock() + + private lateinit var sut: WipeWalletUseCase + + private var onWipeCalled = false + private var onSetWalletExistsStateCalled = false + + @Before + fun setUp() { + wheneverBlocking { lightningRepo.wipeStorage(0) }.thenReturn(Result.success(Unit)) + onWipeCalled = false + onSetWalletExistsStateCalled = false + + sut = WipeWalletUseCase( + backupRepo = backupRepo, + keychain = keychain, + coreService = coreService, + db = db, + settingsStore = settingsStore, + cacheStore = cacheStore, + widgetsStore = widgetsStore, + blocktankRepo = blocktankRepo, + activityRepo = activityRepo, + lightningRepo = lightningRepo, + ) + } + + @Test + fun `invoke should reset all app state in correct order`() = runTest { + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isSuccess) + val inOrder = inOrder( + backupRepo, + keychain, + coreService, + db, + settingsStore, + cacheStore, + widgetsStore, + blocktankRepo, + activityRepo, + lightningRepo + ) + inOrder.verify(backupRepo).setWiping(true) + inOrder.verify(backupRepo).reset() + inOrder.verify(keychain).wipe() + inOrder.verify(coreService).wipeData() + inOrder.verify(db).clearAllTables() + inOrder.verify(settingsStore).reset() + inOrder.verify(cacheStore).reset() + inOrder.verify(widgetsStore).reset() + inOrder.verify(blocktankRepo).resetState() + inOrder.verify(activityRepo).resetState() + assertTrue(onWipeCalled) + inOrder.verify(lightningRepo).wipeStorage(0) + assertTrue(onSetWalletExistsStateCalled) + inOrder.verify(backupRepo).setWiping(false) + } + + @Test + fun `invoke should pass walletIndex to lightningRepo wipeStorage`() = runTest { + val walletIndex = 5 + wheneverBlocking { lightningRepo.wipeStorage(walletIndex) }.thenReturn(Result.success(Unit)) + + val result = sut.invoke( + walletIndex = walletIndex, + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isSuccess) + verify(lightningRepo).wipeStorage(walletIndex) + } + + @Test + fun `invoke should set wiping to false even on failure`() = runTest { + whenever(keychain.wipe()).thenThrow(RuntimeException("Test error")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + verify(backupRepo).setWiping(true) + verify(backupRepo).setWiping(false) + } + + @Test + fun `invoke should return failure when lightningRepo wipeStorage fails`() = runTest { + val error = RuntimeException("Lightning wipe failed") + wheneverBlocking { lightningRepo.wipeStorage(0) }.thenReturn(Result.failure(error)) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + verify(backupRepo).setWiping(false) + } + + @Test + fun `invoke should return failure when database clear fails`() = runTest { + whenever(db.clearAllTables()).thenThrow(RuntimeException("DB clear failed")) + + val result = sut.invoke( + resetWalletState = { onWipeCalled = true }, + onSuccess = { onSetWalletExistsStateCalled = true }, + ) + + assertTrue(result.isFailure) + verify(backupRepo).setWiping(false) + } +} diff --git a/app/src/test/java/to/bitkit/utils/Bip39Test.kt b/app/src/test/java/to/bitkit/utils/Bip39Test.kt deleted file mode 100644 index 44ece6274..000000000 --- a/app/src/test/java/to/bitkit/utils/Bip39Test.kt +++ /dev/null @@ -1,154 +0,0 @@ -package to.bitkit.utils - -import junit.framework.TestCase.assertFalse -import org.junit.Assert -import org.junit.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class Bip39Test { - private fun String.toWordList(): List = this.trim().lowercase().split(" ") - - @Test - fun `test valid mnemonic phrases`() { - // Test vectors based on Trezor's reference implementation - // From https://github.com/trezor/python-mnemonic/blob/master/vectors.json - val testVectors = listOf( - // 12 words (128 bits entropy) - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" to true, - "legal winner thank year wave sausage worth useful legal winner thank yellow" to true, - "letter advice cage absurd amount doctor acoustic avoid letter advice cage above" to true, - "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" to true, - - // 24 words (256 bits entropy) - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art" to true, - "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title" to true, - "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless" to true, - "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote" to true, - "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge" to true, - "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic" to true, - - // Edge cases - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false, // Invalid checksum - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false, // Too few words - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" to false // Invalid length - ) - - for ((mnemonic, expectedResult) in testVectors) { - assertEquals(expectedResult, mnemonic.toWordList().validBip39Checksum(), "Failed for mnemonic: $mnemonic") - } - } - - @Test - fun `test invalid word count`() { - // 11 words (too few) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - - // 13 words (invalid length) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - - // 23 words (invalid length) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - } - - @Test - fun `test invalid words`() { - // Contains a word not in the wordlist - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon invalidword" - ).validBip39Checksum() - ) - } - - @Test - fun `test invalid checksum`() { - // Valid words but invalid checksum - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - } - - @Test - fun `test case sensitivity`() { - // Original valid mnemonic - val validMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" - - // Test with uppercase - assertTrue(validMnemonic.uppercase().toWordList().validBip39Checksum()) - - // Test with mixed case - assertTrue( - "AbAnDoN abandon ABANDON abandon abandon abandon abandon abandon abandon abandon abandon about".toWordList().validBip39Checksum() - ) - } - - @Test - fun `test invalid examples with correct word count`() { - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon" - ).validBip39Checksum() - ) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon zoo" - ).validBip39Checksum() - ) - assertFalse( - listOf( - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon actor" - ).validBip39Checksum() - ) - } - - @Test - fun `isBip39 should return true for valid BIP39 word`() { - Assert.assertTrue("abandon".isBip39()) - Assert.assertTrue("zoo".isBip39()) - Assert.assertTrue("abandon".uppercase().isBip39()) - Assert.assertTrue("abandon".lowercase().isBip39()) - Assert.assertTrue("abandon".capitalize().isBip39()) - } - - @Test - fun `isBip39 should return false for invalid BIP39 word`() { - Assert.assertFalse("invalidword".isBip39()) - Assert.assertFalse("".isBip39()) - Assert.assertFalse("123".isBip39()) - Assert.assertFalse("abandon ".isBip39()) - Assert.assertFalse(" abandon".isBip39()) - Assert.assertFalse(" abandon ".isBip39()) - Assert.assertFalse("abandon1".isBip39()) - Assert.assertFalse("abandon-".isBip39()) - Assert.assertFalse("abandon_".isBip39()) - } - - @Test - fun `isBip39 should handle empty string`() { - Assert.assertFalse("".isBip39()) - } - - @Test - fun `isBip39 should handle non-alphabetic characters`() { - Assert.assertFalse("123".isBip39()) - Assert.assertFalse("!@#".isBip39()) - Assert.assertFalse("abandon1".isBip39()) - Assert.assertFalse("abandon-".isBip39()) - Assert.assertFalse("abandon_".isBip39()) - } -} diff --git a/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt new file mode 100644 index 000000000..e8ee09a42 --- /dev/null +++ b/app/src/test/java/to/bitkit/viewmodels/RestoreWalletViewModelTest.kt @@ -0,0 +1,713 @@ +package to.bitkit.viewmodels + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.services.core.Bip39Service +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class RestoreWalletViewModelTest : BaseUnitTest() { + + private val bip39Service = mock() + + private lateinit var viewModel: RestoreWalletViewModel + + @Before + fun setup() = runBlocking { + whenever(bip39Service.isValidWord(any())).thenReturn(true) + whenever(bip39Service.getSuggestions(any(), any())).thenReturn(emptyList()) + whenever(bip39Service.isValidMnemonicSize(any())).thenReturn(true) + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + + viewModel = RestoreWalletViewModel(bip39Service) + } + + // region Initial State + + @Test + fun `initial state should have 24 empty word slots`() { + val state = viewModel.uiState.value + + assertEquals(24, state.words.size) + assertTrue(state.words.all { it.isEmpty() }) + } + + @Test + fun `initial state should have focused index 0`() { + val state = viewModel.uiState.value + + assertEquals(0, state.focusedIndex) + } + + @Test + fun `initial state should be in 12-word mode`() { + val state = viewModel.uiState.value + + assertFalse(state.is24Words) + assertEquals(12, state.wordCount) + } + + @Test + fun `initial state should have no suggestions`() { + val state = viewModel.uiState.value + + assertTrue(state.suggestions.isEmpty()) + } + + @Test + fun `initial state should have passphrase section hidden`() { + val state = viewModel.uiState.value + + assertFalse(state.showingPassphrase) + assertEquals("", state.bip39Passphrase) + } + + @Test + fun `initial state should have checksumErrorVisible false`() { + val state = viewModel.uiState.value + + assertFalse(state.checksumErrorVisible) + } + + @Test + fun `initial state should have areButtonsEnabled false`() { + val state = viewModel.uiState.value + + assertFalse(state.areButtonsEnabled) + } + + // endregion + + // region Word Input + + @Test + fun `onChangeWord should update word at correct index`() { + viewModel.onChangeWord(5, "abandon") + + val state = viewModel.uiState.value + assertEquals("abandon", state.words[5]) + } + + @Test + fun `onChangeWord should update scrollToFieldIndex`() { + viewModel.onChangeWord(7, "ability") + + val state = viewModel.uiState.value + assertEquals(7, state.scrollToFieldIndex) + } + + @Test + fun `onChangeWord should mark invalid words`() = runBlocking { + whenever(bip39Service.isValidWord("invalid_word")).thenReturn(false) + + viewModel.onChangeWord(3, "invalid_word") + + val state = viewModel.uiState.value + assertTrue(state.invalidWordIndices.contains(3)) + } + + @Test + fun `onChangeWord should clear invalid flag when word becomes valid`() = runBlocking { + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + whenever(bip39Service.isValidWord("valid")).thenReturn(true) + + viewModel.onChangeWord(2, "invalid") + assertTrue(viewModel.uiState.value.invalidWordIndices.contains(2)) + + viewModel.onChangeWord(2, "valid") + assertFalse(viewModel.uiState.value.invalidWordIndices.contains(2)) + } + + // endregion + + // region Paste Handling + + @Test + fun `handlePastedWords should parse 12 words separated by spaces`() { + val words = "abandon ability able about above absent absorb abstract absurd abuse access accident" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("abandon", state.words[0]) + assertEquals("ability", state.words[1]) + assertEquals("accident", state.words[11]) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 24 words separated by spaces`() { + val words = List(24) { "w${it + 1}" }.joinToString(" ") + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w24", state.words[23]) + assertTrue(state.is24Words) + } + + @Test + fun `handlePastedWords should handle multiple whitespace types`() { + val words = "w1\tw2\nw3 w4\t\nw5 w6 w7 w8 w9 w10 w11 w12" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w2", state.words[1]) + assertEquals("w3", state.words[2]) + } + + @Test + fun `handlePastedWords should parse 12 words separated by tabs only`() { + val words = "w1\tw2\tw3\tw4\tw5\tw6\tw7\tw8\tw9\tw10\tw11\tw12" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w2", state.words[1]) + assertEquals("w12", state.words[11]) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 24 words separated by tabs only`() { + val words = List(24) { "w${it + 1}" }.joinToString("\t") + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w24", state.words[23]) + assertTrue(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 12 words separated by newlines only`() { + val words = "w1\nw2\nw3\nw4\nw5\nw6\nw7\nw8\nw9\nw10\nw11\nw12" + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w2", state.words[1]) + assertEquals("w12", state.words[11]) + assertFalse(state.is24Words) + } + + @Test + fun `handlePastedWords should parse 24 words separated by newlines only`() { + val words = List(24) { "w${it + 1}" }.joinToString("\n") + + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w1", state.words[0]) + assertEquals("w24", state.words[23]) + assertTrue(state.is24Words) + } + + @Test + fun `handlePastedWords should clear excess slots when pasting 12 words`() { + // First manually set all 24 words + for (i in 0 until 24) { + viewModel.onChangeWord(i, "word$i") + } + + // Then paste 12 words + val words = "w1 w2 w3 w4 w5 w6 w7 w8 w9 w10 w11 w12" + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertEquals("w12", state.words[11]) + assertEquals("", state.words[12]) + assertEquals("", state.words[23]) + } + + @Test + fun `handlePastedWords should detect invalid words`() = runBlocking { + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + whenever(bip39Service.isValidWord(any())).thenReturn(true) + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + + val words = List(12) { if (it == 2) "invalid" else "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertTrue(state.invalidWordIndices.contains(2)) + } + + @Test + fun `handlePastedWords should dismiss keyboard when all words valid`() { + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertTrue(state.shouldDismissKeyboard) + } + + @Test + fun `handlePastedWords should not dismiss keyboard when words invalid`() = runBlocking { + whenever(bip39Service.isValidWord("invalid")).thenReturn(false) + + val words = List(12) { if (it == 0) "invalid" else "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertFalse(state.shouldDismissKeyboard) + } + + // endregion + + // region Focus Management + + @Test + fun `onChangeWordFocus should set focused index when gaining focus`() { + viewModel.onChangeWordFocus(5, true) + + assertEquals(5, viewModel.uiState.value.focusedIndex) + } + + @Test + fun `onChangeWordFocus should clear focused index when losing focus`() { + viewModel.onChangeWordFocus(5, true) + viewModel.onChangeWordFocus(5, false) + + assertNull(viewModel.uiState.value.focusedIndex) + } + + @Test + fun `onChangeWordFocus should update scrollToFieldIndex`() { + viewModel.onChangeWordFocus(8, true) + + assertEquals(8, viewModel.uiState.value.scrollToFieldIndex) + } + + @Test + fun `onChangeWordFocus should clear suggestions when blurring`() { + viewModel.onChangeWordFocus(0, true) + viewModel.onChangeWordFocus(0, false) + + assertTrue(viewModel.uiState.value.suggestions.isEmpty()) + } + + // endregion + + // region Suggestions + + @Test + fun `updateSuggestions should return suggestions for valid input`() = runBlocking { + whenever(bip39Service.getSuggestions("aba", 3u)).thenReturn(listOf("abandon", "ability", "able")) + + viewModel.onChangeWordFocus(0, true) + viewModel.onChangeWord(0, "aba") + + val state = viewModel.uiState.value + assertEquals(3, state.suggestions.size) + } + + @Test + fun `updateSuggestions should filter exact matches`() = runBlocking { + whenever(bip39Service.getSuggestions("abandon", 3u)).thenReturn(listOf("abandon")) + + viewModel.onChangeWordFocus(0, true) + viewModel.onChangeWord(0, "abandon") + + val state = viewModel.uiState.value + assertTrue(state.suggestions.isEmpty()) + } + + @Test + fun `onSelectSuggestion should apply suggestion to focused word`() { + viewModel.onChangeWordFocus(0, true) + viewModel.onSelectSuggestion("abandon") + + assertEquals("abandon", viewModel.uiState.value.words[0]) + } + + @Test + fun `onSelectSuggestion should clear suggestions`() { + viewModel.onChangeWordFocus(0, true) + viewModel.onSelectSuggestion("abandon") + + assertTrue(viewModel.uiState.value.suggestions.isEmpty()) + } + + // endregion + + // region Passphrase Management + + @Test + fun `onAdvancedClick should toggle showingPassphrase`() { + assertFalse(viewModel.uiState.value.showingPassphrase) + + viewModel.onAdvancedClick() + assertTrue(viewModel.uiState.value.showingPassphrase) + + viewModel.onAdvancedClick() + assertFalse(viewModel.uiState.value.showingPassphrase) + } + + @Test + fun `onAdvancedClick should clear passphrase when toggling`() { + viewModel.onAdvancedClick() + viewModel.onChangePassphrase("test-passphrase") + + viewModel.onAdvancedClick() + + assertEquals("", viewModel.uiState.value.bip39Passphrase) + } + + @Test + fun `onChangePassphrase should update passphrase value`() { + viewModel.onChangePassphrase("my-passphrase-123") + + assertEquals("my-passphrase-123", viewModel.uiState.value.bip39Passphrase) + } + + // endregion + + // region Navigation + + @Test + fun `onBackspaceInEmpty should move focus to previous field`() { + viewModel.onBackspaceInEmpty(5) + + assertEquals(4, viewModel.uiState.value.focusedIndex) + } + + @Test + fun `onBackspaceInEmpty at index 0 should not change focus`() { + viewModel.onBackspaceInEmpty(0) + + assertEquals(0, viewModel.uiState.value.focusedIndex) + } + + // endregion + + // region UX Flags + + @Test + fun `onKeyboardDismiss should reset shouldDismissKeyboard flag`() { + viewModel.onKeyboardDismiss() + + assertFalse(viewModel.uiState.value.shouldDismissKeyboard) + } + + @Test + fun `onScrollComplete should reset scrollToFieldIndex`() { + viewModel.onScrollComplete() + + assertNull(viewModel.uiState.value.scrollToFieldIndex) + } + + // endregion + + // region Computed Properties + + @Test + fun `wordCount should return 12 when is24Words is false`() { + val state = viewModel.uiState.value + + assertFalse(state.is24Words) + assertEquals(12, state.wordCount) + } + + @Test + fun `wordCount should return 24 when is24Words is true`() { + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state = viewModel.uiState.value + assertTrue(state.is24Words) + assertEquals(24, state.wordCount) + } + + @Test + fun `wordsPerColumn should return correct values`() { + val state12 = viewModel.uiState.value + assertEquals(6, state12.wordsPerColumn) + + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + val state24 = viewModel.uiState.value + assertEquals(12, state24.wordsPerColumn) + } + + @Test + fun `areButtonsEnabled should be true with valid mnemonic`() { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be false with checksum error`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + // endregion + + // region Checksum Error Visibility + + @Test + fun `checksumErrorVisible should be true with 12 valid BIP39 words but invalid checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be true with 24 valid BIP39 words but invalid checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words24 = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words24) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false when correcting invalid checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.checksumErrorVisible) + + // Now fix the checksum by mocking success + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + viewModel.onChangeWord(11, "corrected") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false with incomplete mnemonic`() { + for (i in 0 until 6) { + viewModel.onChangeWord(i, "word$i") + } + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false when invalid BIP39 words present`() = runBlocking { + whenever(bip39Service.isValidWord("invalidword")).thenReturn(false) + + for (i in 0 until 11) { + viewModel.onChangeWord(i, "word$i") + } + viewModel.onChangeWord(11, "invalidword") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be true after pasting 12 words with bad checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be true after pasting 24 words with bad checksum`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + } + + @Test + fun `checksumErrorVisible should be false after pasting valid mnemonic`() { + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + } + + // endregion + + // region Buttons Enabled Reactive Updates + + @Test + fun `areButtonsEnabled should remain false during progressive word entry`() { + for (i in 0 until 6) { + viewModel.onChangeWord(i, "word$i") + } + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be true after entering all 12 valid words`() { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be true after entering all 24 valid words`() { + val words = List(24) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be false when changing valid word to invalid`() = runBlocking { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.isValidWord("badword")).thenReturn(false) + viewModel.onChangeWord(5, "badword") + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be false when valid word causes checksum error`() = runBlocking { + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + viewModel.onChangeWord(11, "different") + + assertFalse(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `areButtonsEnabled should be true after pasting valid 12-word mnemonic`() { + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + // endregion + + // region Correction Flows + + @Test + fun `correction flow - invalid words to checksum error to corrected to enabled`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + assertTrue(viewModel.uiState.value.checksumErrorVisible) + assertFalse(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + viewModel.onChangeWord(11, "corrected") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `correction flow - paste invalid mnemonic then correct individual words`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + val words = List(12) { "w${it + 1}" }.joinToString(" ") + viewModel.onChangeWord(0, words) + + assertTrue(viewModel.uiState.value.checksumErrorVisible) + assertFalse(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.success(Unit)) + viewModel.onChangeWord(0, "fixed") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + @Test + fun `correction flow - invalid BIP39 word to valid word enables buttons`() = runBlocking { + whenever(bip39Service.isValidWord("badword")).thenReturn(false) + + for (i in 0 until 11) { + viewModel.onChangeWord(i, "word$i") + } + viewModel.onChangeWord(11, "badword") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertFalse(viewModel.uiState.value.areButtonsEnabled) + + whenever(bip39Service.isValidWord("goodword")).thenReturn(true) + viewModel.onChangeWord(11, "goodword") + + assertFalse(viewModel.uiState.value.checksumErrorVisible) + assertTrue(viewModel.uiState.value.areButtonsEnabled) + } + + // endregion + + // region State Consistency + + @Test + fun `buttons should always be disabled when checksum error visible`() = runBlocking { + whenever(bip39Service.validateMnemonic(any())).thenReturn(Result.failure(Exception("Invalid checksum"))) + + for (i in 0 until 12) { + viewModel.onChangeWord(i, "word$i") + } + + val state = viewModel.uiState.value + assertTrue(state.checksumErrorVisible) + assertFalse(state.areButtonsEnabled) + } + + @Test + fun `buttons should always be disabled when invalid BIP39 words present`() = runBlocking { + whenever(bip39Service.isValidWord("invalidword")).thenReturn(false) + + for (i in 0 until 11) { + viewModel.onChangeWord(i, "word$i") + } + viewModel.onChangeWord(11, "invalidword") + + val state = viewModel.uiState.value + assertFalse(state.checksumErrorVisible) + assertFalse(state.areButtonsEnabled) + assertTrue(state.invalidWordIndices.contains(11)) + } + + // endregion +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 74528cab2..0450146ab 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -177,9 +177,9 @@ complexity: thresholdInInterfaces: 11 thresholdInObjects: 11 thresholdInEnums: 11 - ignoreDeprecated: false - ignorePrivate: false - ignoreOverridden: false + ignoreDeprecated: true + ignorePrivate: true + ignoreOverridden: true ignoreAnnotatedFunctions: ['Preview'] coroutines: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4158f5461..72a6b7408 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,7 +44,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } -bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.23" } +bitkitcore = { module = "com.synonym:bitkit-core-android", version = "0.1.27" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 49c424185..538bf73e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,7 +34,6 @@ dependencyResolutionManagement { mavenLocal() google() mavenCentral() - maven("https://jitpack.io") maven { url = uri("https://maven.pkg.github.com/synonymdev/bitkit-core") credentials { @@ -51,6 +50,7 @@ dependencyResolutionManagement { password = pass } } + maven("https://jitpack.io") } } rootProject.name = "bitkit-android"