From bd97cf4ebdb59153a956f6a2bb0bf83bf22eab74 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 18 Feb 2026 09:20:37 -0500 Subject: [PATCH 01/48] feat: trezor hardware support - Implement trezor hardware support via USB and Bluetooth --- app/build.gradle.kts | 2 +- app/libs/btleplug.aar | Bin 0 -> 25184 bytes app/libs/jni-utils.jar | Bin 0 -> 13116 bytes app/proguard-rules.pro | 9 +- app/src/main/AndroidManifest.xml | 26 + app/src/main/java/to/bitkit/App.kt | 3 + .../java/to/bitkit/repositories/TrezorRepo.kt | 465 +++++++ .../java/to/bitkit/services/BluetoothInit.kt | 70 ++ .../java/to/bitkit/services/TrezorDebugLog.kt | 34 + .../java/to/bitkit/services/TrezorService.kt | 196 +++ .../to/bitkit/services/TrezorTransport.kt | 1109 +++++++++++++++++ app/src/main/java/to/bitkit/ui/ContentView.kt | 8 + .../ui/screens/trezor/AddressSection.kt | 128 ++ .../screens/trezor/ConnectedDeviceSection.kt | 60 + .../ui/screens/trezor/DeviceListSection.kt | 147 +++ .../ui/screens/trezor/PairingCodeDialog.kt | 97 ++ .../ui/screens/trezor/PublicKeySection.kt | 159 +++ .../ui/screens/trezor/SignMessageSection.kt | 133 ++ .../bitkit/ui/screens/trezor/TrezorScreen.kt | 616 +++++++++ .../ui/settings/AdvancedSettingsScreen.kt | 19 + .../ui/settings/AdvancedSettingsViewModel.kt | 6 + .../to/bitkit/viewmodels/TrezorViewModel.kt | 301 +++++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/usb_device_filter.xml | 9 + 24 files changed, 3597 insertions(+), 2 deletions(-) create mode 100644 app/libs/btleplug.aar create mode 100644 app/libs/jni-utils.jar create mode 100644 app/src/main/java/to/bitkit/repositories/TrezorRepo.kt create mode 100644 app/src/main/java/to/bitkit/services/BluetoothInit.kt create mode 100644 app/src/main/java/to/bitkit/services/TrezorDebugLog.kt create mode 100644 app/src/main/java/to/bitkit/services/TrezorService.kt create mode 100644 app/src/main/java/to/bitkit/services/TrezorTransport.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt create mode 100644 app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt create mode 100644 app/src/main/res/xml/usb_device_filter.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 08b67b26c..6e7bbd8fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -215,7 +215,7 @@ composeCompiler { } dependencies { - implementation(fileTree("libs") { include("*.aar") }) + implementation(fileTree("libs") { include("*.aar", "*.jar") }) implementation(libs.jna) { artifact { type = "aar" } } implementation(platform(libs.kotlin.bom)) implementation(libs.core.ktx) diff --git a/app/libs/btleplug.aar b/app/libs/btleplug.aar new file mode 100644 index 0000000000000000000000000000000000000000..eb983b31a79f2e2adf96c642ffac8644fed986a8 GIT binary patch literal 25184 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�~o&rNF+ck?Hg&$K{e}L={g)O_gsDQ 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 Z|Kp&QWZ@D1%N5*5#r@a;m(hRC{s-?2{%ZgL literal 0 HcmV?d00001 diff --git a/app/libs/jni-utils.jar b/app/libs/jni-utils.jar new file mode 100644 index 0000000000000000000000000000000000000000..c11668144514d4254729efe25bed8814a65dd7d3 GIT binary patch literal 13116 zcmb7~bzGIp)4*w@Te?BIyGuHxq(R`&-Q6W1Al)EHgMgHDcXv0afTRc#67S)9y|=jMfGNHi)|qV1olb^5Uu@4AKe`OpsuT zKXk10g*dL1aAPfu)1`Lr4%mWFS z(;=_xlcCs%35x5JsrVBa=Ike`S~@SE=qSghCYKN+=Fj13;NU`4q7R~rt=%pMV%l=j7!eBHKlNLFcJn%Qqmb0;l92s0}R7=fkb#SF3F-1h&t^TdE)&=HYq&9 z#CBqclaNIKfRVnDz6lt(g#KD3>ud0fJz*jYNLW-cjdVmE{ZxDq(|Gx$$r1||3~U4y z42<x~>}>pR6_u(lI^s{`-V#dB3{2qBg-h>YZiU239g7D+DboYmqT;|R9V>R( z!Qm~<0k%yu4UKe9PkeGtRzwjP%-u&UCpi02O;T}LC!Z>;era{euWog8VF>H9w+?~b z=h@<0y>_T`H`m6NfD3kFO{iP!rx*O~$ml;O(U9E$7!P z4#@lDHB!tIO;Z~3wLHkk>Z~1nLu=asaf^1QQC>E;a+tpljfyo2@89cZe)1xr zAUWDm`{5gbx(kO!L(#alPrQ0<+=1H857dY8I_ubKiEMos1F55A10O&~+44;7hQ`CW zD)-VfN!^Q}RXH7B;!R|mG<4JKX`w$_z)l8`dbZK9NocZ{ZnBRQifX&4;bvs&v@91> zj2K9CzKSYmeN8M8 z^Rc-cIT&^<|gfddoucZZF@XMA=X_LX74e>J9}DOvG2b zM4fs~9lctufRr>Jk$S<|xXr5`Ak}o;4SB&MfZ0`H+MMbu6*^0p7v%9ARE|B}$(dhY+AYziGdxEhgqm7$N_3SOLGIF_?XTSxkRz>G* z$I|wmRHH0lKX*8_QjM%1x8Oi+2qQ7Vx*;zlB+o3ihp=}`8elwdf}K%3OW5lgcTUt& zz7EUl6G{xXXRBo}t(HMqrJ*)*6lC9Dcpf#l%U`q9aA|7k*CFN~pdwLfk*4=%k6ZRM zVMaT|MYAS&q7!+*=Hmq?WCH$+;o3t5oHqiUL9}`H`P!X}$N68vqUbc_0SV04I_N&x zIgD`j4p&;}J~^F|ktMG0KGgE^*PLbQG?#D6yEAMv%~<4@q&D^2-1xWo%V+1UKZj`K zGBIv|9h#GjL=$v>f36YPhfPhaje7w`v0sudY3DE0a)OzG!bh@9w@RZ^(AP=D*os|W z{AuPs0Se}UFlHBnS@835=XK-;TT zm%4rQKk!~k?_Ypj>Jq5#GM3fsbbyr}JX>y!nv#O;ZHe*}^L@(cgnuCt1?kWvLdcuh z$$c8JnTrP)h-2{pgTQPDo@?7;#_g8`+QPx=c|hCu{sZWWpz$PzF>agpf{s73w3f; zuy}ogBtO~F$M~_AN)xBm*M6FG8C3y|lzu+cSZe#bav-kmXKN~Fnm>oEMV(y+wEXv%!{L2Cq*IFHa$3oZmgOwZRm z?~}uaG&?tpf%J$*N{y@ROC&1aR61w*WChD4j z8>Z)^7ODa}=2n^dR+I`yLcXC;6jzqY7#~S~Id25b(q7EjKY#Jd_Cx~ADwL%3^u24@ zwn$j!g~@=JnUI*7wonEhOgA#jWJJ8d_&G(BN_i7jCl3FTNNysejQK?ArJi}qF~eE3 zXE)HDT#q?9#D!j>8kWqwz%It>+qYdyv$(q7QflP9##ga zR8@P#cjHq|k8uvU`1Zxvnb42HT#23wP7!c(y#ziif3nGb-Cq@Lt*ric7_$GnFa!b^ zZTu`0NuE^k&kYU9M`=jsAcr)yGx5+flr~qQC5J2tF_F5bDA)O*3tpv^kaPcE(Z%)E zz1nU6sY(`BFl$p_aFI2sfjj5tYBG5@u3Vbp^XE*5yp#N*w`__!ys&I}ew--t~MKAD`;wHPC0x zj!a968MDYw;s|Y`PuGyLG_==Vf1WLGo&?qKzzus|4vjav;zPb1dBW--wLt;4NrhZK zD_4SEN@iMGfF0hA?Rw~1K{0i18SL!^3~92FF;NMfiuKOz0gF+4@Bm68dHg8sWb|UhymKlz=N{QG?^YgPl~Tb#xA#cYYTNT=?nKKU@_jS> z1_VaO(p%my>O2ZJPaIOLMtUi6^oux*crBe&m5!0Njy{s;1dYVP7jel+ zWrr`~6H;|L`r6Vn;VHz~*}cc!y~4%i4rH7Bgf1)dOc@tfgM(d6)Xxx)lT>U@W;js3J!?9N4-1G~Xh*b8+ zLu>En#ai8*{TMFI+}}p=AmzG0%h*0-sO~be_SWrFtx1YJ%U`ZBbO@ah^Fu}BA&!17 z77?0Ji_1ve+V%MCmJ%S>zCfW1eCSxu81_7l{PNh_syecr!cR3MM=nV z^WyUNYy^`eo(z9ts{Ck8wcMPLBH(WMSkKa*)ZlT^)7Y9y38GIj*col1uXoH3i7;k- zi6D?`EI9xjSsyvg8k$iz0(eMy_*OA_>E>VWk-K^k-@poyb)javVrt1n`z^}3hwMl2 zya(C%Wz2)LA%VuS27H+Rz+gnpe>53qr3#?Qpn4+*jomlv9LZ!}3kHuVhLlR7C9R=p z#1OT^>Q*u&KP5jW4eaG8=sGIYx~dI!R_CJ6vLw^ZRwN`hRCU6z zSPfe)S6WYNNp$}aE4%p1JX!*v+01OH9O+=<`i9M#za&g&S*k7^XWcsV%o0onc$8 ztX}dty*}3;z}|5C4M*+py*YL&2nrhdJaq6r*QQQ2UuHs)Q?O1q?GTUGU1q?bJw&Y4 zIOY>1A{;))SNri?B?ZVWY~O&`PsNyn5GQcIfra^9L~!^1O4xi?sI;}+-@#O=>Y~l0 zFlPGja3o+MmNVEvzK+91bP1LSHHr*e8HyUB$ocqyLhZvq4MVTQ0QM0diAapw8ss^3 zmnL6bU8E|xV!Wfv=tyP?;o_Igt=dDd%q()Ur%iqVL1Z$BX}vw3Vl_yn`7QXwX>oqp zQ#Ay1+bnO>jte6=;-N#)X`^v?6H7*3tx*${usQN-KNLCbbkAy7(b?x?dgZe%2QlcZ z_ri@cP+ty-iP;;*-EMwmi$xWqA_@sS4ZZB;!0RM?Vle~5xvqeiXGsPGKVYZbZUz2iljb; ztTyFA_tu1q(z?lQJLINoPx}gYW+ZKP{o!0eBL%F-=acf|FG0u74G9i&;+ckMT+}PM z1IZ3im4#l=Dq2rSyk2S&)l!$Kz&O)p$qAv=n!mxwv4?f_H^X!-FPOpWU$E)$dMM zf-Pzag|I({v-ZBSM=O})I>!@uMFLI%EG# z4z$A-=-*wNeYL}HuQjL@q6(y?VJuctnj^eha4_G`Ep`{?kETW!4F?k^sZr8%b0DBw zzOzG^;BpAK@GeN=Hc3*G{k0x;WhBu=R~J*0(G;G>1!AFAZ`dQ8FRbv?GSo%XIUTQE z3|rWcdY!0x(#FHAgAo&uUESx5sVy#FhtOJ}8)3n#hIk*~Vh|BYk^>B%Q&qV!2bkgE zWeRb4*f2SiMtdF%s;nAB4^`|ub!p2wV_LkRm$n|%XfF=pz*1K-og1uwo^6zZY~M0* z>HB$VWOJOU;UUc~EoYFT@!-e&H$j$&T6$!6{%k)?!k9=y<*6ndXAKdFT zV0WZ0uA-Et`8wqm|ERyUX7q^EHSJc@fK+S##M`vzb>rJhhI%bTddvVuILxq+?BbYl zxXku_#7sRoew~e7#`0m7Y2TlH_zf{`zg?9{1t1#By61Ghr%ccl>Ge z5ybHZ>qIu?k0bi-_YDk}>krRhOT93A+{J*W-WL!pQ#ekw$yv%7XSLL>OOi>sy_hnm zfVU`#=R2G{aJXHToYc3&xV3DV88|%cv*QugyVyUP+=SpQ@)U3%Nrd_{ zll!)HemA)q6>Hf=MNA)s2~O*wSC$!vo|9qLSXC&UC~8o+_o6D>38u?ytwYrfl_wq^ zQ6YXdg(_HkdhK^zSm78)JMC?D#Vk6qD6qwI#-H@*%jiB681ZWh(Lfs#Q_|-U1|L+@ z5f(98EPZ#K=VDqST;M!PR~WNHW5Fjopy)e75UtcDsO)=PGBQ15?(s#y#t@#s zer~zt`0PFYF+H%VMUwg9qZ4u}iYe_)+OvtN{4nWwBsopDD0l+~03qot7SY+2s--|? zQ3|EG2*o+Tm^eg38}6UN7T)shdi`{lrK8<8F^zMJKj(BkPPudHEyqh z-}HGc-YR4milG2r=o7)%&pCh2YcMz^J<2cc^jslC;>W(lsu6m%<%Ym9Bxah@3o$4j zMNl=Pkrs)s2)L$-kp1jLUy*KvBr%ZP^&%{3Z#Z|}EqGSgFmKy-PK`HmX!Xo-g%HM2rmVoW7mPGE^)dVTfP~OxhGpS<5QL zJUzs=cBg+Nts`nEB}Ed6qapS7+58*J#yXpRmy&g^b5nMuG_wtqBT3xRp~Qp&QlI*> zkqo}AB_6i3*~3F2aC&{3K&V)AGhXFw44k>z)x{9gFGzr@hQldQ{}$s}*G?>dql)UZ z9xQ+NT4VO5*YibqNChk0sy+93_s8y~`2%JnWlBsu6T{Wh6!uFojG`qZVs)^_!aehr zeT-sc$;$y?(EcBvv1r3u1BQL|YEeX~Q2Ro9?V<0}-Hu1me5udhY-n;Seh`Pt$)3CU zAOzZJ#&cKj!fO#@j#^D&*HG_)3U690eGYFAh3%X7H47tX@_4n-@E^dKs%dNYinkQY z8VDC43bHZDh=jWiZxXebS5HEfg-);TQ8S{O`PBgEg3j=@>W%dATi#O}i( zW_0oMb9d-A(63e2m}@1P?HTO{uL&6Gcz;WQwgkkdbrLk_iion z`Kib`A~Fv}N3e*}zVHLAhx<&eHu~wu**Irv}F4%tUSq&^KmC2*-Sej<-VYeNE>6D#N7ua!6J*d-~ua zS#&;-I4fYS{5UW>(NMThB4vh^#n&!p`atm20!>ax!_ytGN6cbw#S_eGCdsFr8ps>F zOF_Ji%-Q|;9X5Dj<8|(?)Hy0eni|EAU!ivlZGscw1#R7me)&KfyCsnq_FNP5p3QFE zGN3jMnIe1TahO+J#%Z1)>UB)Dn6>RAGvkjGJE_?XYwgRo8fH%w3Fhhjqb{PpNIWd? z-0%|Bjj+OPUwy$VGs`|~q%kd$>t=hP}1hU`KIovdnp{94_3Rb`G#L5|OP2~arn0`{8; z)5$Fb@=2_i*EPBf7~wSzydIvyzJCS4eaYZVN2OdlI`jaYgJCDhl-Pryh~Aw+8^p`L*4B8XU`cDKw?YIY99%5&kD z`gRMgsauzfY|EvL;|e)ll3()isuYYa>yk*JZacBgSDmdmj=GOHj?8=Bo*!Mhf&pHW z-yI%V!7 zn$}mw({EpN0M25n>5Qq|TQ^j#1X4KEWb=Lc3GYn_OsF|&XSup;MMkOVNv|Y&(CqB{ zg+E|N2$G|#B|}ZS#>p)jYc&nIn|hzN*>TGDz*!&m8(vayZ#V!0VgT;i7wzzj(PnCv9l zHL;#-(D~=fm(s-5qni!x8h6fo!l&uyO;w?3>>g>#gHqT>OBUv~#0*b86Kpy=r+UAo z66b`OT6w%Q&u)o%T0ih1)@F2&+NfWfXIN*&C>{ry-PSE^ zi^A}>;KIP-WGl&-R>>$2aW0IIoj0b#9GUhxr?qqYef_Q@f< zs7wo(e!eMn@-T`LlF?{mEQGq@aY!C5C6?#f{7Pk*&l>i99-meQyc8bIlu}Ye-@dG- zEm=|NvG+hcI>D6;ja5g@j0JZ;cC_j6enkG@5JA|nkw2biwvA)O`aJ#ZwkOmDOm)u_ zk*;n8>W^Ia=pBwAB1h{{yLbcvPGES*zSry-zU@Y6EV-<;ArRjsTaB!Y zEOimlqk3ZC2LsEzHxP|*iimf?KXmFc;-pv-r~mxO6|>i^c6;41ekh|GE_ZBLKmf{T z0Ey}#glF?9`RYp>VQ>p6HAtOSzj7XIr$Fn?hytPgN`{(=_#_?+$!k~wB|C8ud@+blQdnBpJV;?`dzp*$zJ64#VWxsRKaT3_3m^$E^UP{?+yJDor~ZmL=;mKAOS0~dvI z#W`q(f-aEB{)#6u$edk73@)N>f;=zjlU)YVF@p$Rh+Dl(Y@5M~hol=Jx4Z^`5v~`9 zz~3r5`ZEHpa03OQfAQF)&vvF-s#a1n?**)`-jbuu-07~;sM0%%vM)-jIq@bhKl;zg zLF;APR=p=Xn1Fi6gPpX+(%?@gBQ(s;Rby=J0od=6!0_s#M_Cu>EwPHkGiU^bV%?J(3Iww{?B*)}|cd)3kLX!{%_Iv6$B7wH5#^I%|v@awb2-%kApcdj^$+%AlI zlkX$L*^)^6S>W0Uz3WP&q@G5QX!A9pH(=f7+e_~$6o81$h$$m(fe_a6f{L+h5~9L6Zmlc$rkrj z8z7w>@Mk2U8f69q$kgC=BE@49UQ^r*dORjeXM4Q=G!@OZ~R=af3nV zqsKXW(FUGOUuGidat_7y2HS5a+R4P8nCsDM*QX~rXbHls^$at#y3FAAOJ-k5MU9-O zvO2L%_aSf%=HY&M7^0Vtp!6t6spXc8T)T*;)Bf|S2xN#0=~6l?=j|1^m`7d)&0DDp z5&YIG$(2A|ifVRW99=E^b#!MjLjhu90>MCm@si+pW~4n<8or>mnS$_!glKdC)^c>s zeg0iyMOknNOvs;?Oahe)Y^Y%SB6t7)DWC)u11+Gu>wMR8{b_)o|A_sooD!69*Bco5 z{?+cPCcn13fn9)Kfd!Jk61c0O{MzmWQh;B-S5*E=?yjQpYrB)Xd%wQR{j0w6SBSg% z%CGGXahKrxpNPMzEq}!!{f7BV*{ndr{yE3-YwaZ{4z%|28!^}4@lSC7E;9s0gBC7+ zN5=pc?YA@iPZ=X9AGBuUJO4AVaQC--&k&xr5`wEs~y0Xo7R@!NdwY6ZTwI|GYE{M875RStl{Kr08n;d1?leh>Rs9RVm8 zG^zca8;1P*NrI%fLE(3>ZyLBu9e-_i8t_B;75wg|4U`3%Nd894^>6tt3pA|^$_7ob zerJQB{cfHTHs5ZIzn;24*`WE%@9e`rV1G}>{ck#Ga_>7`7~}Ud|4#pY;|)50(6r6> z5&D3R_vdfj&yqNxe9$}k?|eR--}Are{^#=tz2E)Le**OSznlLLx4ocKze9a9?;kxs zHQ}$&f8W1>;y~};zR%SBPjElKjRPGB^pfiPK&8O9^ye!1d*1&V=!YvS&|!YAo*I&0 z4fA*G3yK515&1sr&wqmZ@BIkq0HD$N_W=eU{K0(x9jk+)K||f|=y|F?MuP;tin7qa Uj6N6`5%BL6xWBU81!-Xa2iZa}lmGw# literal 0 HcmV?d00001 diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ff59496d8..97f12cb0e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,11 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# btleplug (droidplug) Android Bluetooth support +# These classes are loaded via JNI from Rust code +-keep class com.nonpolynomial.btleplug.** { *; } + +# jni-utils support library for btleplug +-keep class io.github.gedgygedgy.rust.** { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ebcf34f04..1a4273e8f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,6 +21,24 @@ android:name="android.permission.FOREGROUND_SERVICE" tools:ignore="ForegroundServicePermission,ForegroundServicesPolicy" /> + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 27b3a7c17..eb6e8dd40 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -9,6 +9,7 @@ import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import to.bitkit.env.Env +import to.bitkit.services.BluetoothInit import javax.inject.Inject @HiltAndroidApp @@ -25,6 +26,8 @@ internal open class App : Application(), Configuration.Provider { super.onCreate() currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } Env.initAppStoragePath(filesDir.absolutePath) + // Initialize btleplug for Bluetooth support (required before any BLE usage) + BluetoothInit.ensureInitialized() } companion object { diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt new file mode 100644 index 000000000..6753bca90 --- /dev/null +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -0,0 +1,465 @@ +package to.bitkit.repositories + +import android.content.Context +import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorPublicKeyResponse +import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorSignedMessageResponse +import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorTxInput +import com.synonym.bitkitcore.TrezorTxOutput +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import to.bitkit.env.Env +import to.bitkit.services.TrezorDebugLog +import to.bitkit.services.TrezorService +import to.bitkit.services.TrezorTransport +import to.bitkit.utils.Logger +import javax.inject.Inject +import javax.inject.Singleton + +data class TrezorState( + val isInitialized: Boolean = false, + val isScanning: Boolean = false, + val isConnecting: Boolean = false, + val isAutoReconnecting: Boolean = false, + val knownDevices: List = emptyList(), + val nearbyDevices: List = emptyList(), + val connectedDevice: TrezorFeatures? = null, + val connectedDeviceId: String? = null, + val lastAddress: TrezorAddressResponse? = null, + val lastPublicKey: TrezorPublicKeyResponse? = null, + val error: String? = null, +) + +@Singleton +class TrezorRepo @Inject constructor( + @ApplicationContext private val context: Context, + private val trezorService: TrezorService, + private val trezorTransport: TrezorTransport, +) { + private val prefs by lazy { + context.getSharedPreferences("trezor_device", Context.MODE_PRIVATE) + } + + private val json = Json { ignoreUnknownKeys = true } + + private val _state = MutableStateFlow(TrezorState()) + val state = _state.asStateFlow() + + /** + * Flow indicating when a pairing code needs to be entered. + * UI should show a dialog when this emits true. + */ + val needsPairingCode = trezorTransport.needsPairingCode + + /** + * Submit the pairing code entered by the user. + */ + fun submitPairingCode(code: String) { + trezorTransport.submitPairingCode(code) + } + + /** + * Cancel pairing code entry. + */ + fun cancelPairingCode() { + trezorTransport.cancelPairingCode() + } + + suspend fun initialize(walletIndex: Int = 0): Result = runCatching { + val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json" + Logger.debug("Initializing Trezor with credential path: $credentialPath", context = TAG) + trezorService.initialize(credentialPath) + val known = loadKnownDevices() + _state.update { it.copy(isInitialized = true, knownDevices = known, error = null) } + }.onFailure { e -> + Logger.error("Trezor init failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun scan(): Result> = runCatching { + _state.update { it.copy(isScanning = true, error = null) } + val devices = trezorService.scan() + val knownIds = _state.value.knownDevices.map { it.id }.toSet() + val nearby = devices.filter { it.id !in knownIds } + _state.update { it.copy(isScanning = false, nearbyDevices = nearby) } + devices + }.onFailure { e -> + Logger.error("Trezor scan failed", e, context = TAG) + _state.update { it.copy(isScanning = false, error = e.message) } + } + + suspend fun listDevices(): Result> = runCatching { + val devices = trezorService.listDevices() + val knownIds = _state.value.knownDevices.map { it.id }.toSet() + val nearby = devices.filter { it.id !in knownIds } + _state.update { it.copy(nearbyDevices = nearby) } + devices + }.onFailure { e -> + Logger.error("Trezor listDevices failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun connect(deviceId: String): Result = runCatching { + _state.update { it.copy(isConnecting = true, error = null) } + TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId") + val features = connectWithThpRetry(deviceId) + TrezorDebugLog.log("CONNECT", "connect() succeeded: label=${features.label}, model=${features.model}") + val deviceInfo = _state.value.nearbyDevices.find { it.id == deviceId } + ?: _state.value.knownDevices.find { it.id == deviceId }?.let { known -> + TrezorDeviceInfo( + id = known.id, + transportType = when (known.transportType) { + "bluetooth" -> com.synonym.bitkitcore.TrezorTransportType.BLUETOOTH + else -> com.synonym.bitkitcore.TrezorTransportType.USB + }, + name = known.name, + path = known.path, + label = known.label, + model = known.model, + isBootloader = false, + ) + } + if (deviceInfo != null) { + addOrUpdateKnownDevice(deviceInfo, features) + } + _state.update { + it.copy( + isConnecting = false, + connectedDevice = features, + connectedDeviceId = deviceId, + nearbyDevices = it.nearbyDevices.filter { d -> d.id != deviceId }, + ) + } + features + }.onFailure { e -> + Logger.error("Trezor connect failed", e, context = TAG) + _state.update { it.copy(isConnecting = false, error = e.message) } + } + + suspend fun getAddress( + path: String = "m/84'/0'/0'/0/0", + showOnTrezor: Boolean = false, + scriptType: TrezorScriptType? = TrezorScriptType.SPEND_WITNESS, + ): Result = runCatching { + ensureConnected() + val response = trezorService.getAddress( + path = path, + coin = "Bitcoin", + showOnTrezor = showOnTrezor, + scriptType = scriptType, + ) + _state.update { it.copy(lastAddress = response, error = null) } + response + }.onFailure { e -> + Logger.error("Trezor getAddress failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun getPublicKey( + path: String = "m/84'/0'/0'", + showOnTrezor: Boolean = false, + ): Result = runCatching { + ensureConnected() + val response = trezorService.getPublicKey( + path = path, + coin = "Bitcoin", + showOnTrezor = showOnTrezor, + ) + _state.update { it.copy(lastPublicKey = response, error = null) } + response + }.onFailure { e -> + Logger.error("Trezor getPublicKey failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun disconnect(): Result = runCatching { + TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") + runCatching { trezorService.disconnect() } + _state.update { + it.copy(connectedDevice = null, connectedDeviceId = null, lastAddress = null, lastPublicKey = null) + } + TrezorDebugLog.log("DISCONNECT", "disconnect() complete (credentials NOT cleared)") + }.onFailure { e -> + TrezorDebugLog.log("DISCONNECT", "FAILED: ${e.message}") + Logger.error("Trezor disconnect failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun signMessage( + path: String = "m/84'/0'/0'/0/0", + message: String, + ): Result = runCatching { + ensureConnected() + val response = trezorService.signMessage( + path = path, + message = message, + coin = "Bitcoin", + ) + _state.update { it.copy(error = null) } + response + }.onFailure { e -> + Logger.error("Trezor signMessage failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun verifyMessage( + address: String, + signature: String, + message: String, + ): Result = runCatching { + ensureConnected() + val result = trezorService.verifyMessage( + address = address, + signature = signature, + message = message, + coin = "Bitcoin", + ) + _state.update { it.copy(error = null) } + result + }.onFailure { e -> + Logger.error("Trezor verifyMessage failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty() + + suspend fun autoReconnect(walletIndex: Int = 0): Result { + val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() } + if (knownDevices.isEmpty()) { + return Result.failure(IllegalStateException("No known devices")) + } + + _state.update { it.copy(isAutoReconnecting = true, error = null) } + return runCatching { + if (!_state.value.isInitialized) { + initialize(walletIndex).getOrThrow() + } + if (trezorService.isConnected()) { + _state.value.connectedDevice ?: error("Connected but no features") + } else { + val scannedDevices = scan().getOrThrow() + val match = knownDevices.firstNotNullOfOrNull { known -> + scannedDevices.find { it.id == known.id } + } ?: error("No known device found nearby") + connect(match.id).getOrThrow() + } + }.onSuccess { + _state.update { it.copy(isAutoReconnecting = false) } + }.onFailure { e -> + Logger.error("Auto-reconnect failed", e, context = TAG) + _state.update { it.copy(isAutoReconnecting = false, error = e.message) } + } + } + + suspend fun connectKnownDevice(deviceId: String): Result = runCatching { + _state.update { it.copy(isConnecting = true, error = null) } + TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===") + TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId") + TrezorDebugLog.log("RECONNECT", "isInitialized=${_state.value.isInitialized}") + if (!_state.value.isInitialized) { + TrezorDebugLog.log("RECONNECT", "Initializing...") + initialize().getOrThrow() + TrezorDebugLog.log("RECONNECT", "Initialized OK") + } + TrezorDebugLog.log("RECONNECT", "Scanning for devices...") + val scannedDevices = trezorService.scan() + TrezorDebugLog.log("RECONNECT", "Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}") + val device = scannedDevices.find { it.id == deviceId } + ?: error("Device not found nearby — is it powered on?") + TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}") + TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...") + val features = connectWithThpRetry(device.id) + TrezorDebugLog.log("RECONNECT", "Connected! label=${features.label}, model=${features.model}") + addOrUpdateKnownDevice(device, features) + _state.update { + it.copy(isConnecting = false, connectedDevice = features, connectedDeviceId = deviceId) + } + TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice SUCCESS ===") + features + }.onFailure { e -> + TrezorDebugLog.log("RECONNECT", "FAILED: ${e.message}") + Logger.error("Connect known device failed", e, context = TAG) + _state.update { it.copy(isConnecting = false, error = e.message) } + } + + suspend fun forgetDevice(deviceId: String): Result = runCatching { + TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") + if (_state.value.connectedDeviceId == deviceId) { + runCatching { trezorService.disconnect() } + _state.update { it.copy(connectedDevice = null, connectedDeviceId = null) } + } + TrezorDebugLog.log("FORGET", "Clearing credentials...") + trezorTransport.clearDeviceCredential(deviceId) + runCatching { trezorService.clearCredentials(deviceId) } + val updated = _state.value.knownDevices.filter { it.id != deviceId } + saveKnownDevices(updated) + _state.update { it.copy(knownDevices = updated) } + TrezorDebugLog.log("FORGET", "Device forgotten successfully") + Logger.info("Forgot device: $deviceId", context = TAG) + }.onFailure { e -> + TrezorDebugLog.log("FORGET", "FAILED: ${e.message}") + Logger.error("Forget device failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + fun clearError() { + _state.update { it.copy(error = null) } + } + + fun observeExternalDisconnects(scope: CoroutineScope) { + trezorTransport.externalDisconnect.onEach { path -> + val currentId = _state.value.connectedDeviceId ?: return@onEach + val knownDevice = _state.value.knownDevices.find { it.path == path } + if (knownDevice?.id == currentId || path.contains(currentId)) { + Logger.warn("External disconnect detected for $currentId", context = TAG) + _state.update { + it.copy(connectedDevice = null, connectedDeviceId = null, error = "Device disconnected") + } + } + }.launchIn(scope) + } + + private fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { + val existing = _state.value.knownDevices + val known = KnownDevice( + id = deviceInfo.id, + name = deviceInfo.name, + path = deviceInfo.path, + transportType = when (deviceInfo.transportType) { + com.synonym.bitkitcore.TrezorTransportType.BLUETOOTH -> "bluetooth" + com.synonym.bitkitcore.TrezorTransportType.USB -> "usb" + }, + label = features.label ?: deviceInfo.label, + model = features.model ?: deviceInfo.model, + lastConnectedAt = System.currentTimeMillis(), + ) + val updated = existing.filter { it.id != known.id } + known + saveKnownDevices(updated) + _state.update { it.copy(knownDevices = updated) } + } + + private fun loadKnownDevices(): List = runCatching { + val str = prefs.getString(KEY_KNOWN_DEVICES, null) ?: return emptyList() + json.decodeFromString>(str) + }.onFailure { + Logger.error("Failed to load known devices", it, context = TAG) + }.getOrDefault(emptyList()) + + private fun saveKnownDevices(devices: List) { + runCatching { + prefs.edit().putString(KEY_KNOWN_DEVICES, json.encodeToString(devices)).commit() + }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } + } + + private suspend fun ensureConnected() { + if (trezorService.isConnected()) return + val deviceId = _state.value.connectedDeviceId + ?: _state.value.knownDevices.firstOrNull()?.id + ?: error("No device to reconnect") + if (!_state.value.isInitialized) { + initialize().getOrThrow() + } + val devices = trezorService.scan() + val device = devices.find { it.id == deviceId } + ?: error("Device not found during reconnect") + val features = connectWithThpRetry(device.id) + _state.update { it.copy(connectedDevice = features, connectedDeviceId = deviceId) } + } + + suspend fun signTx( + inputs: List, + outputs: List, + coin: String = "Bitcoin", + lockTime: UInt? = null, + version: UInt? = null, + ): Result = runCatching { + ensureConnected() + val response = trezorService.signTx( + inputs = inputs, + outputs = outputs, + coin = coin, + lockTime = lockTime, + version = version, + ) + _state.update { it.copy(error = null) } + response + }.onFailure { e -> + Logger.error("Trezor signTx failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun clearCredentials(deviceId: String): Result = runCatching { + trezorService.clearCredentials(deviceId) + _state.update { it.copy(error = null) } + }.onFailure { e -> + Logger.error("Trezor clearCredentials failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + private suspend fun connectWithThpRetry(deviceId: String): TrezorFeatures { + TrezorDebugLog.log("THPRetry", "First connect attempt for: $deviceId") + logCredentialFileState(deviceId, "BEFORE 1st attempt") + return try { + val result = trezorService.connect(deviceId) + logCredentialFileState(deviceId, "AFTER 1st attempt (success)") + TrezorDebugLog.log("THPRetry", "First attempt succeeded") + result + } catch (e: Exception) { + logCredentialFileState(deviceId, "AFTER 1st attempt (failed)") + TrezorDebugLog.log("THPRetry", "First attempt failed: ${e.message}") + if (!isRetryableError(e)) { + TrezorDebugLog.log("THPRetry", "Error not retryable, throwing") + throw e + } + TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") + Logger.warn("Connection failed for $deviceId, retrying: ${e.message}", context = TAG) + logCredentialFileState(deviceId, "BEFORE 2nd attempt") + val result = trezorService.connect(deviceId) + logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") + TrezorDebugLog.log("THPRetry", "Second attempt succeeded") + result + } + } + + private fun logCredentialFileState(deviceId: String, label: String) { + val sanitizedId = deviceId.replace(":", "_").replace("/", "_") + val credDir = java.io.File(context.filesDir, "trezor-thp-credentials") + val credFile = java.io.File(credDir, "$sanitizedId.json") + val exists = credFile.exists() + val size = if (exists) credFile.length() else 0 + TrezorDebugLog.log("CRED", "$label: file=$sanitizedId.json exists=$exists size=$size") + } + + private fun isRetryableError(e: Exception): Boolean { + val msg = e.message?.lowercase() ?: return false + return "thp" in msg || "session" in msg || "timeout" in msg || "disconnect" in msg + } + + companion object { + private const val TAG = "TrezorRepo" + private const val KEY_KNOWN_DEVICES = "known_devices" + } +} + +@Serializable +data class KnownDevice( + val id: String, + val name: String?, + val path: String, + val transportType: String, + val label: String?, + val model: String?, + val lastConnectedAt: Long, +) diff --git a/app/src/main/java/to/bitkit/services/BluetoothInit.kt b/app/src/main/java/to/bitkit/services/BluetoothInit.kt new file mode 100644 index 000000000..d3f61f514 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/BluetoothInit.kt @@ -0,0 +1,70 @@ +package to.bitkit.services + +import to.bitkit.utils.Logger + +/** + * Helper object to initialize btleplug (droidplug) on Android. + * This must be called before using any Bluetooth functionality with Trezor devices. + * + * The initialization is performed via JNI to the Rust bitkitcore library, + * which in turn initializes btleplug's Android Bluetooth adapter. + */ +object BluetoothInit { + private const val TAG = "BluetoothInit" + private var initialized = false + private var initResult = false + + init { + // We must load the native library before calling JNI functions. + // UniFFI loads it lazily, but we need it now for Bluetooth init. + try { + System.loadLibrary("bitkitcore") + Logger.info("Loaded bitkitcore native library", context = TAG) + } catch (e: UnsatisfiedLinkError) { + Logger.error("Failed to load bitkitcore native library", e, context = TAG) + } + } + + /** + * Native JNI function to initialize btleplug on Android. + * This function name must match the Rust JNI function name pattern: + * Java_to_bitkit_services_BluetoothInit_nativeInit + */ + private external fun nativeInit(): Boolean + + /** + * Ensures Bluetooth is initialized for btleplug usage. + * This is idempotent - subsequent calls after the first will return + * the cached result without re-initializing. + * + * @return true if initialization succeeded, false otherwise + */ + @Synchronized + fun ensureInitialized(): Boolean { + if (!initialized) { + try { + initResult = nativeInit() + initialized = true + if (initResult) { + Logger.info("Bluetooth (btleplug) initialized successfully", context = TAG) + } else { + Logger.error("Bluetooth (btleplug) initialization returned false", context = TAG) + } + } catch (e: UnsatisfiedLinkError) { + Logger.error("Failed to initialize Bluetooth - native method not found", e, context = TAG) + initialized = true + initResult = false + } catch (e: Exception) { + Logger.error("Failed to initialize Bluetooth", e, context = TAG) + initialized = true + initResult = false + } + } + return initResult + } + + /** + * Returns whether Bluetooth has been successfully initialized. + */ + fun isInitialized(): Boolean = initialized && initResult +} diff --git a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt new file mode 100644 index 000000000..4d2033ed3 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt @@ -0,0 +1,34 @@ +package to.bitkit.services + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object TrezorDebugLog { + private const val MAX_LINES = 300 + private val _lines = MutableStateFlow>(emptyList()) + val lines: StateFlow> = _lines.asStateFlow() + + private val fmt = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + + fun log(tag: String, msg: String) { + val ts = fmt.format(Date()) + val line = "$ts [$tag] $msg" + synchronized(this) { + val current = _lines.value.toMutableList() + current.add(line) + if (current.size > MAX_LINES) { + _lines.value = current.takeLast(MAX_LINES) + } else { + _lines.value = current + } + } + } + + fun clear() { + _lines.value = emptyList() + } +} diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt new file mode 100644 index 000000000..32a2e77bd --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -0,0 +1,196 @@ +package to.bitkit.services + +import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorGetAddressParams +import com.synonym.bitkitcore.TrezorGetPublicKeyParams +import com.synonym.bitkitcore.TrezorPublicKeyResponse +import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorSignMessageParams +import com.synonym.bitkitcore.TrezorSignTxParams +import com.synonym.bitkitcore.TrezorSignedMessageResponse +import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorTxInput +import com.synonym.bitkitcore.TrezorTxOutput +import com.synonym.bitkitcore.TrezorVerifyMessageParams +import com.synonym.bitkitcore.trezorClearCredentials +import com.synonym.bitkitcore.trezorConnect +import com.synonym.bitkitcore.trezorDisconnect +import com.synonym.bitkitcore.trezorGetAddress +import com.synonym.bitkitcore.trezorGetConnectedDevice +import com.synonym.bitkitcore.trezorGetPublicKey +import com.synonym.bitkitcore.trezorInitialize +import com.synonym.bitkitcore.trezorIsConnected +import com.synonym.bitkitcore.trezorIsInitialized +import com.synonym.bitkitcore.trezorListDevices +import com.synonym.bitkitcore.trezorScan +import com.synonym.bitkitcore.trezorSetTransportCallback +import com.synonym.bitkitcore.trezorSignMessage +import com.synonym.bitkitcore.trezorSignTx +import com.synonym.bitkitcore.trezorVerifyMessage +import to.bitkit.async.ServiceQueue +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TrezorService @Inject constructor( + private val transport: TrezorTransport, +) { + @Volatile + private var callbackRegistered = false + + private fun ensureCallbackRegistered() { + if (!callbackRegistered) { + synchronized(this) { + if (!callbackRegistered) { + trezorSetTransportCallback(transport) + callbackRegistered = true + } + } + } + } + + suspend fun initialize(credentialPath: String? = null) { + ServiceQueue.CORE.background { + ensureCallbackRegistered() + trezorInitialize(credentialPath = credentialPath) + } + } + + suspend fun isInitialized(): Boolean { + return ServiceQueue.CORE.background { + trezorIsInitialized() + } + } + + suspend fun scan(): List { + return ServiceQueue.CORE.background { + trezorScan() + } + } + + suspend fun listDevices(): List { + return ServiceQueue.CORE.background { + trezorListDevices() + } + } + + suspend fun connect(deviceId: String): TrezorFeatures { + return ServiceQueue.CORE.background { + trezorConnect(deviceId = deviceId) + } + } + + suspend fun isConnected(): Boolean { + return ServiceQueue.CORE.background { + trezorIsConnected() + } + } + + suspend fun getAddress( + path: String, + coin: String? = "Bitcoin", + showOnTrezor: Boolean = false, + scriptType: TrezorScriptType? = null, + ): TrezorAddressResponse { + return ServiceQueue.CORE.background { + trezorGetAddress( + params = TrezorGetAddressParams( + path = path, + coin = coin, + showOnTrezor = showOnTrezor, + scriptType = scriptType, + ) + ) + } + } + + suspend fun getPublicKey( + path: String, + coin: String? = "Bitcoin", + showOnTrezor: Boolean = false, + ): TrezorPublicKeyResponse { + return ServiceQueue.CORE.background { + trezorGetPublicKey( + params = TrezorGetPublicKeyParams( + path = path, + coin = coin, + showOnTrezor = showOnTrezor, + ) + ) + } + } + + suspend fun disconnect() { + ServiceQueue.CORE.background { + trezorDisconnect() + } + } + + suspend fun getConnectedDevice(): TrezorDeviceInfo? { + return ServiceQueue.CORE.background { + trezorGetConnectedDevice() + } + } + + suspend fun signMessage( + path: String, + message: String, + coin: String? = "Bitcoin", + ): TrezorSignedMessageResponse { + return ServiceQueue.CORE.background { + trezorSignMessage( + params = TrezorSignMessageParams( + path = path, + message = message, + coin = coin, + ) + ) + } + } + + suspend fun verifyMessage( + address: String, + signature: String, + message: String, + coin: String? = "Bitcoin", + ): Boolean { + return ServiceQueue.CORE.background { + trezorVerifyMessage( + params = TrezorVerifyMessageParams( + address = address, + signature = signature, + message = message, + coin = coin, + ) + ) + } + } + + suspend fun signTx( + inputs: List, + outputs: List, + coin: String? = "Bitcoin", + lockTime: UInt? = null, + version: UInt? = null, + ): TrezorSignedTx { + return ServiceQueue.CORE.background { + trezorSignTx( + params = TrezorSignTxParams( + inputs = inputs, + outputs = outputs, + coin = coin, + lockTime = lockTime, + version = version, + ) + ) + } + } + + suspend fun clearCredentials(deviceId: String) { + ServiceQueue.CORE.background { + trezorClearCredentials(deviceId = deviceId) + } + } +} diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt new file mode 100644 index 000000000..c186f6d47 --- /dev/null +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -0,0 +1,1109 @@ +package to.bitkit.services + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.ParcelUuid +import com.synonym.bitkitcore.NativeDeviceInfo +import com.synonym.bitkitcore.TrezorCallMessageResult +import com.synonym.bitkitcore.TrezorTransportCallback +import com.synonym.bitkitcore.TrezorTransportReadResult +import com.synonym.bitkitcore.TrezorTransportWriteResult +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import to.bitkit.utils.Logger +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Transport callback implementation for Trezor communication. + * + * This class implements the [TrezorTransportCallback] interface which is called by + * the Rust bitkit-core module for USB/Bluetooth I/O operations. + * + * USB communication uses 64-byte chunks, Bluetooth uses 244-byte chunks. + */ +@Singleton +class TrezorTransport @Inject constructor( + @ApplicationContext private val context: Context, +) : TrezorTransportCallback { + + private val usbManager: UsbManager by lazy { + context.getSystemService(Context.USB_SERVICE) as UsbManager + } + + private val bluetoothManager: BluetoothManager by lazy { + context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + } + + private val credentialDir: File by lazy { + File(context.filesDir, "trezor-thp-credentials").also { it.mkdirs() } + } + + @Volatile + private var userInitiatedClose = false + + private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1) + val externalDisconnect: SharedFlow = _externalDisconnect + + @Volatile + private var espMigrated = false + + private fun ensureEspMigration() { + if (espMigrated) return + synchronized(this) { + if (espMigrated) return + espMigrated = true + try { + val espPrefs = context.getSharedPreferences( + "trezor_thp_credentials", + Context.MODE_PRIVATE, + ) + val allEntries = espPrefs.all + if (allEntries.isEmpty()) return + var migrated = 0 + for ((key, value) in allEntries) { + if (!key.startsWith("thp_credential_") || value !is String) continue + val sanitizedId = key.removePrefix("thp_credential_") + val file = File(credentialDir, "$sanitizedId.json") + file.writeText(value) + migrated++ + } + if (migrated > 0) { + espPrefs.edit().clear().commit() + Logger.info("Migrated $migrated THP credentials from SharedPreferences to files", context = TAG) + } + } catch (e: Exception) { + Logger.warn("ESP migration failed (may be inaccessible): ${e.message}", context = TAG) + } + } + } + + private val bluetoothAdapter: BluetoothAdapter? by lazy { + bluetoothManager.adapter + } + + // USB connections + private val usbConnections = ConcurrentHashMap() + + // BLE connections + private val bleConnections = ConcurrentHashMap() + private val discoveredBleDevices = ConcurrentHashMap() + + private data class UsbOpenDevice( + val connection: UsbDeviceConnection, + val usbInterface: UsbInterface, + val readEndpoint: UsbEndpoint, + val writeEndpoint: UsbEndpoint, + ) + + private data class BleConnection( + val gatt: BluetoothGatt, + var readCharacteristic: BluetoothGattCharacteristic?, + var writeCharacteristic: BluetoothGattCharacteristic?, + val readQueue: LinkedBlockingQueue = LinkedBlockingQueue(), + @Volatile var isConnected: Boolean = false, + @Volatile var connectionLatch: CountDownLatch? = null, + @Volatile var writeLatch: CountDownLatch? = null, + @Volatile var disconnectLatch: CountDownLatch? = null, + @Volatile var writeStatus: Int = BluetoothGatt.GATT_SUCCESS, + ) + + // ==================== TrezorTransportCallback Implementation ==================== + + override fun enumerateDevices(): List { + val devices = mutableListOf() + + // Enumerate USB devices + try { + val usbDevices = usbManager.deviceList.values + .filter { isTrezorDevice(it) } + .map { device -> + NativeDeviceInfo( + path = device.deviceName, + transportType = "usb", + name = try { device.productName } catch (_: SecurityException) { null }, + vendorId = device.vendorId.toUShort(), + productId = device.productId.toUShort(), + ) + } + devices.addAll(usbDevices) + Logger.debug("USB enumerate found ${usbDevices.size} Trezor device(s)", context = TAG) + } catch (e: Exception) { + Logger.error("USB enumerate failed", e, context = TAG) + } + + // Enumerate Bluetooth devices + try { + val bleDevices = enumerateBleDevices() + devices.addAll(bleDevices) + Logger.debug("BLE enumerate found ${bleDevices.size} Trezor device(s)", context = TAG) + } catch (e: Exception) { + Logger.error("BLE enumerate failed", e, context = TAG) + } + + Logger.info("Total enumerate found ${devices.size} Trezor device(s)", context = TAG) + TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: ${devices.map { "${it.path} (${it.transportType})" }}") + return devices + } + + override fun openDevice(path: String): TrezorTransportWriteResult { + TrezorDebugLog.log("OPEN", "openDevice: $path") + return if (isBleDevice(path)) { + openBleDevice(path) + } else { + openUsbDevice(path) + } + } + + override fun closeDevice(path: String): TrezorTransportWriteResult { + TrezorDebugLog.log("CLOSE", "closeDevice: $path") + return if (isBleDevice(path)) { + closeBleDevice(path) + } else { + closeUsbDevice(path) + } + } + + override fun readChunk(path: String): TrezorTransportReadResult { + return if (isBleDevice(path)) { + readBleChunk(path) + } else { + readUsbChunk(path) + } + } + + override fun writeChunk(path: String, data: ByteArray): TrezorTransportWriteResult { + return if (isBleDevice(path)) { + writeBleChunk(path, data) + } else { + writeUsbChunk(path, data) + } + } + + override fun getChunkSize(path: String): UInt { + return if (isBleDevice(path)) { + BLE_CHUNK_SIZE.toUInt() + } else { + USB_CHUNK_SIZE.toUInt() + } + } + + override fun callMessage( + path: String, + messageType: UShort, + data: ByteArray + ): TrezorCallMessageResult? { + // For BLE/THP devices, the Rust side now handles THP protocol directly. + // This callback returns null to let Rust use its built-in THP implementation. + Logger.debug("callMessage called for $path, type=$messageType - returning null (Rust handles THP)", context = TAG) + return null + } + + override fun getPairingCode(): String { + // This is called by Rust during BLE THP pairing when the device + // displays a 6-digit code that must be entered. + // + // We use a blocking approach with a latch. The UI observes needsPairingCode + // and shows a dialog. When the user enters the code, submitPairingCode() + // is called which releases the latch. + TrezorDebugLog.log("PAIR", ">>> PAIRING CODE REQUESTED - Device requires re-pairing! <<<") + Logger.info(">>> PAIRING CODE REQUESTED <<<", context = TAG) + Logger.info("Look at your Trezor screen for a 6-digit code", context = TAG) + + val latch = CountDownLatch(1) + + synchronized(pairingCodeLock) { + submittedPairingCode = "" + pairingCodeRequest = PairingCodeRequest(isRequested = true, latch = latch) + _needsPairingCode.value = true + } + + try { + // Wait for user to enter the code (with timeout) + val received = latch.await(PAIRING_CODE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + + if (!received) { + Logger.warn("Pairing code entry timed out", context = TAG) + _needsPairingCode.value = false + return "" + } + + val code = submittedPairingCode + Logger.info("Pairing code received (len=${code.length})", context = TAG) + return code + } catch (e: InterruptedException) { + Logger.error("Pairing code wait interrupted", e, context = TAG) + _needsPairingCode.value = false + return "" + } + } + + /** + * Pairing code request state for UI observation. + * When getPairingCode() is called by Rust, we set this to true and wait. + */ + data class PairingCodeRequest( + val isRequested: Boolean = false, + val latch: CountDownLatch? = null, + ) + + @Volatile + private var pairingCodeRequest: PairingCodeRequest = PairingCodeRequest() + + @Volatile + private var submittedPairingCode: String = "" + + private val pairingCodeLock = Object() + + /** + * Flow to observe when a pairing code is needed. + * UI should show a dialog when this is true. + */ + private val _needsPairingCode = MutableStateFlow(false) + val needsPairingCode: kotlinx.coroutines.flow.StateFlow = _needsPairingCode + + /** + * Submit a pairing code from the UI. + * This unblocks the getPairingCode() call waiting on the Rust side. + */ + fun submitPairingCode(code: String) { + synchronized(pairingCodeLock) { + Logger.info("Pairing code submitted (len=${code.length})", context = TAG) + submittedPairingCode = code + _needsPairingCode.value = false + pairingCodeRequest.latch?.countDown() + } + } + + /** + * Cancel pairing code entry (submit empty string). + */ + fun cancelPairingCode() { + submitPairingCode("") + } + + override fun saveThpCredential(deviceId: String, credentialJson: String): Boolean { + ensureEspMigration() + return try { + val file = credentialFile(deviceId) + TrezorDebugLog.log("SAVE", "saveThpCredential called for: $deviceId") + TrezorDebugLog.log("SAVE", "File path: ${file.absolutePath}") + TrezorDebugLog.log("SAVE", "Credential length: ${credentialJson.length}") + + if (credentialJson.isEmpty()) { + val existed = file.exists() + file.delete() + TrezorDebugLog.log("SAVE", "CLEARED credential (file existed=$existed)") + Logger.info("Cleared THP credential for device: $deviceId (path=${file.absolutePath})", context = TAG) + return true + } + + file.writeText(credentialJson) + + // Immediately verify the file was written + val verifyExists = file.exists() + val verifySize = if (verifyExists) file.length() else 0 + TrezorDebugLog.log("SAVE", "Wrote ${credentialJson.length} chars -> verify: exists=$verifyExists, size=$verifySize") + if (!verifyExists || verifySize == 0L) { + TrezorDebugLog.log("SAVE", "WARNING: File verification FAILED after write!") + } + + Logger.info("Saving THP credential to: ${file.absolutePath} (${credentialJson.length} chars)", context = TAG) + true + } catch (e: Exception) { + TrezorDebugLog.log("SAVE", "EXCEPTION: ${e.message}") + Logger.error("Failed to save THP credential", e, context = TAG) + false + } + } + + override fun logDebug(tag: String, message: String) { + TrezorDebugLog.log("RUST:$tag", message) + } + + override fun loadThpCredential(deviceId: String): String? { + ensureEspMigration() + return try { + val file = credentialFile(deviceId) + val exists = file.exists() + val size = if (exists) file.length() else 0 + TrezorDebugLog.log("LOAD", "loadThpCredential for: $deviceId") + TrezorDebugLog.log("LOAD", "File: ${file.absolutePath}, exists=$exists, size=$size") + + // List all files in credential directory for debugging + val allFiles = credentialDir.listFiles()?.map { "${it.name} (${it.length()}b)" } ?: emptyList() + TrezorDebugLog.log("LOAD", "All credential files: $allFiles") + + Logger.info( + "Loading THP credential from: ${file.absolutePath}, exists=$exists, size=$size", + context = TAG, + ) + if (exists) { + val json = file.readText() + TrezorDebugLog.log("LOAD", "Loaded ${json.length} chars, blank=${json.isBlank()}") + if (json.isBlank()) { + TrezorDebugLog.log("LOAD", "WARNING: File exists but is blank! Returning null.") + null + } else { + Logger.info("Loaded THP credential for device: $deviceId (${json.length} chars)", context = TAG) + json + } + } else { + TrezorDebugLog.log("LOAD", "No credential file found -> returning null") + Logger.debug("No stored THP credential for device: $deviceId", context = TAG) + null + } + } catch (e: Exception) { + TrezorDebugLog.log("LOAD", "EXCEPTION: ${e.message}") + Logger.error("Failed to load THP credential", e, context = TAG) + null + } + } + + fun clearDeviceCredential(deviceId: String) { + try { + val file = credentialFile(deviceId) + TrezorDebugLog.log("CLEAR", "clearDeviceCredential for: $deviceId, exists=${file.exists()}") + file.delete() + Logger.info("Cleared device credential for: $deviceId", context = TAG) + } catch (e: Exception) { + TrezorDebugLog.log("CLEAR", "EXCEPTION: ${e.message}") + Logger.error("Failed to clear device credential", e, context = TAG) + } + } + + private fun credentialFile(deviceId: String): File { + val sanitizedId = deviceId.replace(":", "_").replace("/", "_") + return File(credentialDir, "$sanitizedId.json") + } + + // ==================== USB Methods ==================== + + /** + * Request USB permission for a device and block until the user responds. + * Returns true if permission was granted, false otherwise. + * + * This uses a BroadcastReceiver + CountDownLatch pattern because openDevice + * runs on a background thread (Rust FFI callback), not the main thread. + */ + private fun requestUsbPermission(device: UsbDevice): Boolean { + val latch = CountDownLatch(1) + var granted = false + + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + if (intent.action == ACTION_USB_PERMISSION) { + granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + latch.countDown() + } + } + } + + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + context.registerReceiver( + receiver, + IntentFilter(ACTION_USB_PERMISSION), + Context.RECEIVER_NOT_EXPORTED, + ) + + try { + Logger.info("Requesting USB permission for ${device.deviceName}", context = TAG) + usbManager.requestPermission(device, permissionIntent) + + // Block until user responds (up to 60 seconds) + val responded = latch.await(USB_PERMISSION_TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (!responded) { + Logger.warn("USB permission request timed out", context = TAG) + return false + } + + Logger.info("USB permission ${if (granted) "granted" else "denied"} for ${device.deviceName}", context = TAG) + return granted + } finally { + try { context.unregisterReceiver(receiver) } catch (_: Exception) {} + } + } + + private fun openUsbDevice(path: String): TrezorTransportWriteResult { + return try { + // Close existing connection if any + closeUsbDevice(path) + + val device = usbManager.deviceList[path] + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + + if (!usbManager.hasPermission(device)) { + Logger.info("USB permission not yet granted, requesting...", context = TAG) + if (!requestUsbPermission(device)) { + return TrezorTransportWriteResult(success = false, error = "USB permission denied for $path") + } + } + + val connection = usbManager.openDevice(device) + ?: return TrezorTransportWriteResult(success = false, error = "Failed to open device: $path") + + val usbInterface = device.getInterface(0) + if (!connection.claimInterface(usbInterface, true)) { + connection.close() + return TrezorTransportWriteResult(success = false, error = "Failed to claim interface") + } + + var readEndpoint: UsbEndpoint? = null + var writeEndpoint: UsbEndpoint? = null + + for (i in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(i) + when { + endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + endpoint.direction == UsbConstants.USB_DIR_IN -> { + readEndpoint = endpoint + } + endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + endpoint.direction == UsbConstants.USB_DIR_OUT -> { + writeEndpoint = endpoint + } + } + } + + if (readEndpoint == null || writeEndpoint == null) { + connection.releaseInterface(usbInterface) + connection.close() + return TrezorTransportWriteResult(success = false, error = "Could not find required endpoints") + } + + usbConnections[path] = UsbOpenDevice(connection, usbInterface, readEndpoint, writeEndpoint) + Logger.info("USB device opened: $path", context = TAG) + TrezorTransportWriteResult(success = true, error = "") + } catch (e: Exception) { + Logger.error("USB open failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + } + } + + private fun closeUsbDevice(path: String): TrezorTransportWriteResult { + return try { + val openDevice = usbConnections.remove(path) + ?: return TrezorTransportWriteResult(success = true, error = "") + + openDevice.connection.releaseInterface(openDevice.usbInterface) + openDevice.connection.close() + Logger.info("USB device closed: $path", context = TAG) + TrezorTransportWriteResult(success = true, error = "") + } catch (e: Exception) { + Logger.error("USB close failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + } + } + + private fun readUsbChunk(path: String): TrezorTransportReadResult { + return try { + val openDevice = usbConnections[path] + ?: return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Device not open: $path", + ) + + val buffer = ByteArray(USB_CHUNK_SIZE) + val bytesRead = openDevice.connection.bulkTransfer( + openDevice.readEndpoint, + buffer, + buffer.size, + READ_TIMEOUT_MS, + ) + + if (bytesRead < 0) { + return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Read failed: $bytesRead", + ) + } + + val data = buffer.copyOf(bytesRead) + Logger.debug("USB read $bytesRead bytes from $path", context = TAG) + TrezorTransportReadResult(success = true, data = data, error = "") + } catch (e: Exception) { + Logger.error("USB read failed", e, context = TAG) + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") + } + } + + private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { + return try { + val openDevice = usbConnections[path] + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + + val bytesWritten = openDevice.connection.bulkTransfer( + openDevice.writeEndpoint, + data, + data.size, + WRITE_TIMEOUT_MS, + ) + + if (bytesWritten < 0) { + return TrezorTransportWriteResult(success = false, error = "Write failed: $bytesWritten") + } + + Logger.debug("USB wrote $bytesWritten bytes to $path", context = TAG) + TrezorTransportWriteResult(success = true, error = "") + } catch (e: Exception) { + Logger.error("USB write failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") + } + } + + // ==================== Bluetooth Methods ==================== + + @SuppressLint("MissingPermission") + private fun enumerateBleDevices(): List { + if (bluetoothAdapter?.isEnabled != true) { + Logger.warn("Bluetooth is not enabled", context = TAG) + return emptyList() + } + + val scanner = bluetoothAdapter?.bluetoothLeScanner ?: return emptyList() + + // Start fresh scan + discoveredBleDevices.clear() + + val scanFilter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(SERVICE_UUID)) + .build() + + val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + + scanner.startScan(listOf(scanFilter), scanSettings, bleScanCallback) + Logger.debug("BLE scan started", context = TAG) + + // Wait for scan results + Thread.sleep(SCAN_DURATION_MS) + + scanner.stopScan(bleScanCallback) + Logger.debug("BLE scan stopped", context = TAG) + + return discoveredBleDevices.values.map { device -> + NativeDeviceInfo( + path = "ble:${device.address}", + transportType = "bluetooth", + name = device.name ?: "Trezor", + vendorId = null, + productId = null, + ) + } + } + + private val bleScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val device = result.device + val address = device.address + if (!discoveredBleDevices.containsKey(address)) { + discoveredBleDevices[address] = device + Logger.debug("BLE device found: $address (${device.name})", context = TAG) + } + } + + override fun onScanFailed(errorCode: Int) { + Logger.error("BLE scan failed: $errorCode", context = TAG) + } + } + + @SuppressLint("MissingPermission") + private fun openBleDevice(path: String): TrezorTransportWriteResult { + val address = path.removePrefix("ble:") + val device = discoveredBleDevices[address] + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + + // Close existing connection + closeBleDevice(path) + + // Check if device needs bonding + if (device.bondState == BluetoothDevice.BOND_NONE) { + Logger.info("Device not bonded, initiating bonding: $address", context = TAG) + val bondResult = device.createBond() + if (!bondResult) { + return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") + } + // Wait for bonding to complete + var bondAttempts = 0 + while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < 60) { + Thread.sleep(500) + bondAttempts++ + if (device.bondState == BluetoothDevice.BOND_NONE) { + return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected") + } + } + if (device.bondState != BluetoothDevice.BOND_BONDED) { + return TrezorTransportWriteResult(success = false, error = "Bonding timeout") + } + Logger.info("Device bonded successfully: $address", context = TAG) + } else if (device.bondState == BluetoothDevice.BOND_BONDING) { + Logger.info("Device is currently bonding, waiting: $address", context = TAG) + var bondAttempts = 0 + while (device.bondState == BluetoothDevice.BOND_BONDING && bondAttempts < 60) { + Thread.sleep(500) + bondAttempts++ + } + if (device.bondState != BluetoothDevice.BOND_BONDED) { + return TrezorTransportWriteResult(success = false, error = "Bonding failed") + } + } else { + Logger.info("Device already bonded: $address", context = TAG) + } + + val connectionLatch = CountDownLatch(1) + val gatt = device.connectGatt(context, false, bleGattCallback) + + val connection = BleConnection( + gatt = gatt, + readCharacteristic = null, + writeCharacteristic = null, + connectionLatch = connectionLatch + ) + + bleConnections[path] = connection + + if (!connectionLatch.await(CONNECTION_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + closeBleDevice(path) + return TrezorTransportWriteResult(success = false, error = "Connection timeout") + } + + val updatedConnection = bleConnections[path] + if (updatedConnection == null || !updatedConnection.isConnected) { + closeBleDevice(path) + return TrezorTransportWriteResult(success = false, error = "Failed to connect") + } + + // Request high-priority BLE connection for faster, more reliable handshake + gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + + // Drain any stale notifications from a previous connection attempt + val staleCount = updatedConnection.readQueue.size + if (staleCount > 0) { + updatedConnection.readQueue.clear() + TrezorDebugLog.log("OPEN", "Drained $staleCount stale notifications from read queue") + } + + // Stabilization delay: device THP layer needs time after BLE reconnect + Thread.sleep(BLE_CONNECTION_STABILIZATION_MS) + + Logger.info("BLE device opened: $path", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + + @SuppressLint("MissingPermission") + private fun closeBleDevice(path: String): TrezorTransportWriteResult { + val connection = bleConnections.remove(path) + ?: return TrezorTransportWriteResult(success = true, error = "") + + userInitiatedClose = true + try { + val disconnectLatch = CountDownLatch(1) + bleConnections[path] = connection.copy(disconnectLatch = disconnectLatch) + + connection.gatt.disconnect() + + val disconnected = disconnectLatch.await(DISCONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (!disconnected) { + Logger.warn("BLE disconnect timeout, forcing close: $path", context = TAG) + } + + bleConnections.remove(path) + connection.gatt.close() + Thread.sleep(100) + } catch (e: Exception) { + Logger.error("BLE close failed", e, context = TAG) + } + + Logger.info("BLE device closed: $path", context = TAG) + return TrezorTransportWriteResult(success = true, error = "") + } + + private fun readBleChunk(path: String): TrezorTransportReadResult { + val connection = bleConnections[path] + ?: return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Device not open: $path" + ) + + return try { + val data = connection.readQueue.poll(BLE_READ_TIMEOUT_MS, TimeUnit.MILLISECONDS) + ?: return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Read timeout" + ) + + Logger.debug("BLE read ${data.size} bytes from $path", context = TAG) + TrezorTransportReadResult(success = true, data = data, error = "") + } catch (e: Exception) { + Logger.error("BLE read failed", e, context = TAG) + TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Read failed") + } + } + + @SuppressLint("MissingPermission") + private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { + val connection = bleConnections[path] + ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") + + val writeChar = connection.writeCharacteristic + ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available") + + if (!connection.isConnected) { + Logger.warn("BLE write attempted on disconnected device: $path", context = TAG) + return TrezorTransportWriteResult(success = false, error = "Device disconnected") + } + + return try { + // Retry logic for transient GATT busy states + var lastError = "Write initiation failed" + for (attempt in 1..BLE_WRITE_RETRY_COUNT) { + val writeLatch = CountDownLatch(1) + connection.writeLatch = writeLatch + connection.writeStatus = BluetoothGatt.GATT_SUCCESS + + @Suppress("DEPRECATION") + writeChar.value = data + @Suppress("DEPRECATION") + val success = connection.gatt.writeCharacteristic(writeChar) + + if (!success) { + // Get more diagnostic info + val connState = connection.isConnected + val charProps = writeChar.properties + Logger.warn( + "BLE write initiation failed (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path, " + + "isConnected=$connState, charProps=0x${charProps.toString(16)}, dataLen=${data.size}", + context = TAG + ) + if (attempt < BLE_WRITE_RETRY_COUNT) { + Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) + continue + } + return TrezorTransportWriteResult(success = false, error = lastError) + } + + if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { + lastError = "Write timeout" + Logger.warn("BLE write timeout (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", context = TAG) + if (attempt < BLE_WRITE_RETRY_COUNT) { + Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) + continue + } + return TrezorTransportWriteResult(success = false, error = lastError) + } + + if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { + lastError = "Write callback failed: ${connection.writeStatus}" + Logger.warn("BLE write callback failed with status ${connection.writeStatus} (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", context = TAG) + if (attempt < BLE_WRITE_RETRY_COUNT) { + Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) + continue + } + return TrezorTransportWriteResult(success = false, error = lastError) + } + + // Success! + Logger.debug("BLE wrote ${data.size} bytes to $path (attempt $attempt)", context = TAG) + + // Small delay between writes to avoid overwhelming the GATT + Thread.sleep(BLE_WRITE_INTER_DELAY_MS) + + return TrezorTransportWriteResult(success = true, error = "") + } + + TrezorTransportWriteResult(success = false, error = lastError) + } catch (e: Exception) { + Logger.error("BLE write failed", e, context = TAG) + TrezorTransportWriteResult(success = false, error = e.message ?: "Write failed") + } + } + + @SuppressLint("MissingPermission") + private val bleGattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] + + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + Logger.debug("BLE connected, requesting MTU: $path", context = TAG) + val mtuResult = gatt.requestMtu(256) + if (!mtuResult) { + Logger.warn("MTU request failed, proceeding with service discovery: $path", context = TAG) + gatt.discoverServices() + } + } + BluetoothProfile.STATE_DISCONNECTED -> { + Logger.debug("BLE disconnected: $path", context = TAG) + connection?.isConnected = false + connection?.connectionLatch?.countDown() + connection?.disconnectLatch?.countDown() + if (!userInitiatedClose) { + _externalDisconnect.tryEmit(path) + } + userInitiatedClose = false + } + } + } + + override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { + val path = "ble:${gatt.device.address}" + if (status == BluetoothGatt.GATT_SUCCESS) { + Logger.info("MTU changed to $mtu for $path", context = TAG) + } else { + Logger.warn("MTU change failed with status $status for $path", context = TAG) + } + gatt.discoverServices() + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + + if (status != BluetoothGatt.GATT_SUCCESS) { + Logger.error("Service discovery failed: $status", context = TAG) + connection.connectionLatch?.countDown() + return + } + + val service = gatt.getService(SERVICE_UUID) + if (service == null) { + Logger.error("Trezor service not found", context = TAG) + connection.connectionLatch?.countDown() + return + } + + val writeChar = service.getCharacteristic(WRITE_CHAR_UUID) + val notifyChar = service.getCharacteristic(NOTIFY_CHAR_UUID) + + if (writeChar == null || notifyChar == null) { + Logger.error("Required characteristics not found", context = TAG) + connection.connectionLatch?.countDown() + return + } + + // Use WRITE_TYPE_DEFAULT (with response) for more reliable writes + // Some Trezor devices don't handle NO_RESPONSE well + @Suppress("DEPRECATION") + writeChar.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + + gatt.setCharacteristicNotification(notifyChar, true) + + // Also subscribe to PUSH characteristic + val pushChar = service.getCharacteristic(PUSH_CHAR_UUID) + if (pushChar != null) { + gatt.setCharacteristicNotification(pushChar, true) + } + + connection.readCharacteristic = notifyChar + connection.writeCharacteristic = writeChar + connection.isConnected = false + + // Enable notifications via CCCD descriptor for TX characteristic + val descriptor = notifyChar.getDescriptor(CCCD_UUID) + if (descriptor != null) { + @Suppress("DEPRECATION") + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + @Suppress("DEPRECATION") + val writeResult = gatt.writeDescriptor(descriptor) + if (!writeResult) { + Logger.warn("CCCD descriptor write failed to initiate: $path", context = TAG) + // Also enable CCCD for PUSH characteristic before signaling ready + enablePushCccd(gatt, pushChar, path) + connection.isConnected = true + connection.connectionLatch?.countDown() + } + } else { + Logger.warn("CCCD descriptor not found, proceeding: $path", context = TAG) + enablePushCccd(gatt, pushChar, path) + connection.isConnected = true + connection.connectionLatch?.countDown() + } + + Logger.info("BLE services discovered: $path", context = TAG) + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic + ) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + + // Only process notifications from the NOTIFY characteristic + if (characteristic.uuid != NOTIFY_CHAR_UUID) { + Logger.debug("Ignoring notification from non-TX char: ${characteristic.uuid}", context = TAG) + return + } + + @Suppress("DEPRECATION") + val data = characteristic.value + + if (data != null && data.isNotEmpty()) { + connection.readQueue.offer(data) + Logger.debug("BLE TX notification: ${data.size} bytes", context = TAG) + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int + ) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + connection.writeStatus = status + if (status != BluetoothGatt.GATT_SUCCESS) { + Logger.warn("BLE write callback status: $status for $path", context = TAG) + } + connection.writeLatch?.countDown() + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int + ) { + val path = "ble:${gatt.device.address}" + val connection = bleConnections[path] ?: return + + Thread.sleep(200) + + if (status == BluetoothGatt.GATT_SUCCESS) { + Logger.info("CCCD descriptor write complete for ${descriptor.characteristic.uuid}: $path", context = TAG) + } else { + Logger.warn("CCCD descriptor write failed with status $status for ${descriptor.characteristic.uuid}: $path", context = TAG) + } + + // If this was the TX characteristic CCCD, also enable PUSH CCCD + if (descriptor.characteristic.uuid == NOTIFY_CHAR_UUID) { + val pushChar = gatt.getService(SERVICE_UUID)?.getCharacteristic(PUSH_CHAR_UUID) + if (!enablePushCccd(gatt, pushChar, path)) { + // PUSH CCCD not available or failed, signal ready now + connection.isConnected = true + connection.connectionLatch?.countDown() + } + // If enablePushCccd returned true, onDescriptorWrite will fire again for PUSH + } else { + // This was the PUSH CCCD write (or other), signal connection ready + connection.isConnected = true + connection.connectionLatch?.countDown() + } + } + } + + @SuppressLint("MissingPermission") + private fun enablePushCccd( + gatt: BluetoothGatt, + pushChar: BluetoothGattCharacteristic?, + path: String, + ): Boolean { + if (pushChar == null) return false + val pushDescriptor = pushChar.getDescriptor(CCCD_UUID) ?: return false + @Suppress("DEPRECATION") + pushDescriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + @Suppress("DEPRECATION") + val result = gatt.writeDescriptor(pushDescriptor) + if (!result) { + Logger.warn("PUSH CCCD descriptor write failed to initiate: $path", context = TAG) + } + return result + } + + // ==================== Utility Methods ==================== + + private fun isBleDevice(path: String): Boolean = path.startsWith("ble:") + + private fun isTrezorDevice(device: UsbDevice): Boolean { + return device.vendorId == TREZOR_VENDOR_ID_1 || device.vendorId == TREZOR_VENDOR_ID_2 + } + + fun hasUsbPermission(devicePath: String): Boolean { + val device = usbManager.deviceList[devicePath] ?: return false + return usbManager.hasPermission(device) + } + + fun getUsbDevice(devicePath: String): UsbDevice? { + return usbManager.deviceList[devicePath] + } + + fun closeAllConnections() { + usbConnections.keys.toList().forEach { path -> closeUsbDevice(path) } + bleConnections.keys.toList().forEach { path -> closeBleDevice(path) } + } + + companion object { + private const val TAG = "TrezorTransport" + private const val ACTION_USB_PERMISSION = "to.bitkit.USB_PERMISSION" + + // USB constants + private const val USB_CHUNK_SIZE = 64 + private const val USB_PERMISSION_TIMEOUT_MS = 60_000L + private const val TREZOR_VENDOR_ID_1 = 0x1209 + private const val TREZOR_VENDOR_ID_2 = 0x534c + + // BLE constants + private const val BLE_CHUNK_SIZE = 244 + private val SERVICE_UUID = UUID.fromString("8c000001-a59b-4d58-a9ad-073df69fa1b1") + private val WRITE_CHAR_UUID = UUID.fromString("8c000002-a59b-4d58-a9ad-073df69fa1b1") + private val NOTIFY_CHAR_UUID = UUID.fromString("8c000003-a59b-4d58-a9ad-073df69fa1b1") + private val PUSH_CHAR_UUID = UUID.fromString("8c000004-a59b-4d58-a9ad-073df69fa1b1") + private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + // Timeouts + private const val READ_TIMEOUT_MS = 5000 + private const val WRITE_TIMEOUT_MS = 5000 + private const val SCAN_DURATION_MS = 3000L + private const val CONNECTION_TIMEOUT_MS = 10000L + private const val BLE_READ_TIMEOUT_MS = 5000L + private const val DISCONNECT_TIMEOUT_MS = 3000L + private const val PAIRING_CODE_TIMEOUT_MS = 120000L // 2 minutes to enter code + + // BLE write retry settings + private const val BLE_WRITE_RETRY_COUNT = 3 + private const val BLE_WRITE_RETRY_DELAY_MS = 100L + private const val BLE_WRITE_INTER_DELAY_MS = 20L + private const val BLE_CONNECTION_STABILIZATION_MS = 1000L + } +} diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 45ae8f1d5..3173e32db 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -65,6 +65,7 @@ import to.bitkit.ui.screens.profile.ProfileIntroScreen import to.bitkit.ui.screens.recovery.RecoveryMnemonicScreen import to.bitkit.ui.screens.recovery.RecoveryModeScreen import to.bitkit.ui.screens.scanner.QrScanningScreen +import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.scanner.SCAN_REQUEST_KEY import to.bitkit.ui.screens.settings.DevSettingsScreen import to.bitkit.ui.screens.settings.FeeSettingsScreen @@ -1060,6 +1061,9 @@ private fun NavGraphBuilder.advancedSettings(navController: NavHostController) { composableWithDefaultTransitions { NodeInfoScreen(navController) } + composableWithDefaultTransitions { + TrezorScreen(navController) + } } private fun NavGraphBuilder.aboutSettings(navController: NavHostController) { @@ -1751,6 +1755,10 @@ sealed interface Routes { @Serializable data object AddressViewer : Routes + @Serializable + data object Trezor : Routes + + @Serializable data object SweepNav : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt new file mode 100644 index 000000000..eb3b487de --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -0,0 +1,128 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.repositories.TrezorState +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState + +@Composable +internal fun AddressSection( + trezorState: TrezorState, + uiState: TrezorUiState, + onGetAddress: (Boolean) -> Unit, + onIncrementIndex: () -> Unit, +) { + Column { + Text( + text = "Address Generation", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Path: ${uiState.derivationPath}", + color = Colors.White50, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = if (uiState.isGettingAddress) "Getting..." else "Get Address", + onClick = { onGetAddress(false) }, + enabled = !uiState.isGettingAddress, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = if (uiState.isGettingAddress) "Getting..." else "Show on Device", + onClick = { onGetAddress(true) }, + enabled = !uiState.isGettingAddress, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + + AnimatedVisibility(visible = trezorState.lastAddress != null) { + trezorState.lastAddress?.let { response -> + val onCopyAddress = copyToClipboard(text = response.address, label = "Address") + Column { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Address:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = response.address, + color = Colors.Brand, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy address", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyAddress) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + SecondaryButton( + text = "Next Index", + onClick = onIncrementIndex, + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt new file mode 100644 index 000000000..8a0dd0e62 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt @@ -0,0 +1,60 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.TrezorFeatures +import to.bitkit.ui.theme.Colors + +@Composable +internal fun ConnectedDeviceInfo(features: TrezorFeatures) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(Colors.White06) + .padding(12.dp) + ) { + InfoRow("Label", features.label ?: "-") + InfoRow("Model", features.model ?: "-") + InfoRow( + "Firmware", + "${features.majorVersion ?: 0}.${features.minorVersion ?: 0}.${features.patchVersion ?: 0}" + ) + InfoRow("PIN", if (features.pinProtection == true) "Enabled" else "Disabled") + InfoRow("Passphrase", if (features.passphraseProtection == true) "Enabled" else "Disabled") + } +} + +@Composable +internal fun InfoRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = Colors.White50, + fontSize = 12.sp, + ) + Text( + text = value, + color = Colors.White, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt new file mode 100644 index 000000000..a1f968e4c --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -0,0 +1,147 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorTransportType +import to.bitkit.R +import to.bitkit.repositories.KnownDevice +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors + +@Composable +internal fun DeviceCard( + device: TrezorDeviceInfo, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, Colors.White16, RoundedCornerShape(12.dp)) + .background(Colors.White06) + .clickableAlpha(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource( + when (device.transportType) { + TrezorTransportType.USB -> R.drawable.ic_git_branch + TrezorTransportType.BLUETOOTH -> R.drawable.ic_broadcast + } + ), + contentDescription = null, + tint = Colors.White64, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.label ?: device.name ?: device.model ?: "Trezor", + color = Colors.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = when (device.transportType) { + TrezorTransportType.USB -> "USB" + TrezorTransportType.BLUETOOTH -> "Bluetooth" + }, + color = Colors.White50, + fontSize = 12.sp, + ) + } + } +} + +@Composable +internal fun KnownDeviceCard( + device: KnownDevice, + isConnected: Boolean, + onClick: () -> Unit, + onForget: () -> Unit, +) { + val borderColor = if (isConnected) Colors.Green else Colors.White16 + val backgroundColor = if (isConnected) Colors.Green.copy(alpha = 0.08f) else Colors.White06 + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, borderColor, RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickableAlpha(onClick = onClick) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource( + if (device.transportType == "bluetooth") { + R.drawable.ic_broadcast + } else { + R.drawable.ic_git_branch + } + ), + contentDescription = null, + tint = if (isConnected) Colors.Green else Colors.White64, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.label ?: device.name ?: device.model ?: "Trezor", + color = Colors.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (device.transportType == "bluetooth") "Bluetooth" else "USB", + color = Colors.White50, + fontSize = 12.sp, + ) + Text( + text = if (isConnected) "Connected" else "Disconnected", + color = if (isConnected) Colors.Green else Colors.White32, + fontSize = 12.sp, + ) + } + } + Icon( + painter = painterResource(R.drawable.ic_trash), + contentDescription = "Forget device", + tint = Colors.White32, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onForget) + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt new file mode 100644 index 000000000..f79da3571 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -0,0 +1,97 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.ui.theme.Colors + +@Composable +internal fun PairingCodeDialog( + onSubmit: (String) -> Unit, + onCancel: () -> Unit, +) { + var code by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onCancel, + containerColor = Colors.Gray5, + title = { + Text( + text = "Enter Pairing Code", + color = Colors.White, + fontWeight = FontWeight.SemiBold, + ) + }, + text = { + Column { + Text( + text = "Enter the 6-digit code shown on your Trezor device:", + color = Colors.White80, + fontSize = 14.sp, + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = code, + onValueChange = { newValue -> + if (newValue.all { it.isDigit() } && newValue.length <= 6) { + code = newValue + } + }, + placeholder = { + Text("000000", color = Colors.White32) + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + ), + textStyle = androidx.compose.ui.text.TextStyle( + fontFamily = FontFamily.Monospace, + fontSize = 24.sp, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + letterSpacing = 8.sp, + ), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onSubmit(code) }, + enabled = code.length == 6, + ) { + Text( + "Submit", + color = if (code.length == 6) Colors.Brand else Colors.White32, + ) + } + }, + dismissButton = { + TextButton(onClick = onCancel) { + Text("Cancel", color = Colors.White64) + } + }, + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt new file mode 100644 index 000000000..b9a4bdade --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -0,0 +1,159 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.Arrangement +import to.bitkit.R +import to.bitkit.repositories.TrezorState +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState + +@Composable +internal fun PublicKeySection( + trezorState: TrezorState, + uiState: TrezorUiState, + onGetPublicKey: (Boolean) -> Unit, +) { + val accountPath = remember(uiState.derivationPath) { + uiState.derivationPath.split("/").take(4).joinToString("/") + } + + Column { + Text( + text = "Public Key (xpub)", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Account path: $accountPath", + color = Colors.White50, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + SecondaryButton( + text = if (uiState.isGettingPublicKey) "Getting..." else "Get xpub", + onClick = { onGetPublicKey(false) }, + enabled = !uiState.isGettingPublicKey, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = if (uiState.isGettingPublicKey) "Getting..." else "Show on Device", + onClick = { onGetPublicKey(true) }, + enabled = !uiState.isGettingPublicKey, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + + AnimatedVisibility(visible = trezorState.lastPublicKey != null) { + trezorState.lastPublicKey?.let { response -> + val onCopyXpub = copyToClipboard(text = response.xpub, label = "xpub") + val onCopyPublicKey = copyToClipboard(text = response.publicKey, label = "Public Key") + Column { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "xpub:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = response.xpub, + color = Colors.Brand, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy xpub", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyXpub) + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Public Key:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = response.publicKey, + color = Colors.Brand, + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy public key", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyPublicKey) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt new file mode 100644 index 000000000..edcd758eb --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -0,0 +1,133 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState + +@Composable +internal fun SignMessageSection( + uiState: TrezorUiState, + onMessageChange: (String) -> Unit, + onSignMessage: () -> Unit, + onVerifyMessage: () -> Unit, +) { + Column { + Text( + text = "Sign Message", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.messageToSign, + onValueChange = onMessageChange, + label = { Text("Message", color = Colors.White50) }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + ), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + PrimaryButton( + text = if (uiState.isSigningMessage) "Signing..." else "Sign Message", + onClick = onSignMessage, + enabled = !uiState.isSigningMessage && !uiState.isVerifyingMessage && uiState.messageToSign.isNotBlank(), + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + SecondaryButton( + text = if (uiState.isVerifyingMessage) "Verifying..." else "Verify", + onClick = onVerifyMessage, + enabled = uiState.lastSignature != null && !uiState.isVerifyingMessage && !uiState.isSigningMessage, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + + AnimatedVisibility(visible = uiState.lastSignature != null) { + uiState.lastSignature?.let { sig -> + val onCopySignature = copyToClipboard(text = sig, label = "Signature") + Column { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Signature:", + color = Colors.White50, + fontSize = 11.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Brand.copy(alpha = 0.1f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = sig, + color = Colors.Brand, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy signature", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopySignature) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt new file mode 100644 index 000000000..5117b1553 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -0,0 +1,616 @@ +package to.bitkit.ui.screens.trezor + +import android.Manifest +import android.os.Build +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorTransportType +import to.bitkit.R +import to.bitkit.repositories.KnownDevice +import to.bitkit.repositories.TrezorState +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.Text13Up +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.DrawerNavIcon +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.services.TrezorDebugLog +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState +import to.bitkit.viewmodels.TrezorViewModel + +private val bluetoothPermissions: List + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + ) + } else { + listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } + +@Composable +fun TrezorScreen(navController: NavController) { + TrezorScreenContent(onBack = { navController.popBackStack() }) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun TrezorScreenContent( + viewModel: TrezorViewModel = hiltViewModel(), + onBack: () -> Unit = {}, +) { + val trezorState by viewModel.trezorState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val needsPairingCode by viewModel.needsPairingCode.collectAsStateWithLifecycle() + + val permissionsState = rememberMultiplePermissionsState(bluetoothPermissions) + + LaunchedEffect(Unit) { + viewModel.initialize() + } + + val onScanWithPermissions: () -> Unit = { + if (permissionsState.allPermissionsGranted) { + viewModel.scan() + } else { + permissionsState.launchMultiplePermissionRequest() + } + } + + if (needsPairingCode) { + PairingCodeDialog( + onSubmit = viewModel::submitPairingCode, + onCancel = viewModel::cancelPairingCode, + ) + } + + ScreenColumn { + AppTopBar( + titleText = stringResource(R.string.settings__adv__trezor), + onBackClick = onBack, + actions = { DrawerNavIcon() }, + ) + TrezorContent( + trezorState = trezorState, + uiState = uiState, + onInitialize = viewModel::initialize, + onScan = onScanWithPermissions, + onConnectNearby = viewModel::connect, + onConnectKnown = viewModel::connectKnownDevice, + onForgetDevice = viewModel::forgetDevice, + onGetAddress = viewModel::getAddress, + onGetPublicKey = viewModel::getPublicKey, + onIncrementIndex = viewModel::incrementAddressIndex, + onDisconnect = viewModel::disconnect, + onSignMessage = viewModel::signMessage, + onVerifyMessage = viewModel::verifyMessage, + onMessageChange = viewModel::setMessageToSign, + onClearError = viewModel::clearError, + permissionsGranted = permissionsState.allPermissionsGranted, + ) + } +} + +@Composable +private fun TrezorContent( + trezorState: TrezorState, + uiState: TrezorUiState, + onInitialize: () -> Unit = {}, + onScan: () -> Unit = {}, + onConnectNearby: (String) -> Unit = {}, + onConnectKnown: (String) -> Unit = {}, + onForgetDevice: (KnownDevice) -> Unit = {}, + onGetAddress: (Boolean) -> Unit = {}, + onGetPublicKey: (Boolean) -> Unit = {}, + onIncrementIndex: () -> Unit = {}, + onDisconnect: () -> Unit = {}, + onSignMessage: () -> Unit = {}, + onVerifyMessage: () -> Unit = {}, + onMessageChange: (String) -> Unit = {}, + onClearError: () -> Unit = {}, + permissionsGranted: Boolean = true, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + ) { + Text13Up("TREZOR TEST", color = Colors.White64) + VerticalSpacer(16.dp) + + Card( + colors = CardDefaults.cardColors(containerColor = Colors.White08), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + StatusRow(trezorState) + + Spacer(modifier = Modifier.height(16.dp)) + + ActionButtonsRow( + trezorState = trezorState, + onInitialize = onInitialize, + onScan = onScan, + onDisconnect = onDisconnect, + permissionsGranted = permissionsGranted, + ) + + // Known Devices Section + AnimatedVisibility( + visible = trezorState.knownDevices.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "My Devices (${trezorState.knownDevices.size})", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + trezorState.knownDevices.forEach { device -> + val isConnected = trezorState.connectedDeviceId == device.id + KnownDeviceCard( + device = device, + isConnected = isConnected, + onClick = { + if (!isConnected && !trezorState.isConnecting) { + onConnectKnown(device.id) + } + }, + onForget = { onForgetDevice(device) }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + // Nearby Devices Section + AnimatedVisibility( + visible = trezorState.nearbyDevices.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "New Devices (${trezorState.nearbyDevices.size})", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + trezorState.nearbyDevices.forEach { device -> + DeviceCard( + device = device, + onClick = { + if (!trezorState.isConnecting) { + onConnectNearby(device.id) + } + }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + // Connected Device Info + AnimatedVisibility( + visible = trezorState.connectedDevice != null, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + trezorState.connectedDevice?.let { features -> + Column { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Connected Device", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + ConnectedDeviceInfo(features) + + Spacer(modifier = Modifier.height(16.dp)) + + AddressSection( + trezorState = trezorState, + uiState = uiState, + onGetAddress = onGetAddress, + onIncrementIndex = onIncrementIndex, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + PublicKeySection( + trezorState = trezorState, + uiState = uiState, + onGetPublicKey = onGetPublicKey, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SignMessageSection( + uiState = uiState, + onMessageChange = onMessageChange, + onSignMessage = onSignMessage, + onVerifyMessage = onVerifyMessage, + ) + } + } + } + + // Error Display + AnimatedVisibility(visible = trezorState.error != null) { + trezorState.error?.let { error -> + val onCopyError = copyToClipboard(text = error, label = "Trezor Error") + Column { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Red.copy(alpha = 0.1f)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Text( + text = error, + color = Colors.Red, + fontSize = 12.sp, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy error", + tint = Colors.Red, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyError) + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_x), + contentDescription = "Dismiss error", + tint = Colors.Red, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onClearError) + ) + } + } + } + } + + // Debug Log Window + DebugLogSection() + } + } + } +} + +@Composable +private fun DebugLogSection() { + var expanded by remember { mutableStateOf(false) } + val debugLines by TrezorDebugLog.lines.collectAsStateWithLifecycle() + + Column { + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SecondaryButton( + text = if (expanded) "Hide (${debugLines.size})" else "Show Log (${debugLines.size})", + onClick = { expanded = !expanded }, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + if (expanded) { + val onCopyLogs = copyToClipboard( + text = debugLines.joinToString("\n"), + label = "Trezor Debug Log", + ) + SecondaryButton( + text = "Copy", + onClick = onCopyLogs, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + SecondaryButton( + text = "Clear", + onClick = { TrezorDebugLog.clear() }, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + val listState = rememberLazyListState() + + LaunchedEffect(debugLines.size) { + if (debugLines.isNotEmpty()) { + listState.animateScrollToItem(debugLines.size - 1) + } + } + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 400.dp) + .padding(top = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Colors.Black.copy(alpha = 0.5f)) + .padding(8.dp), + ) { + items(debugLines) { line -> + Text( + text = line, + color = Colors.White80, + fontSize = 9.sp, + lineHeight = 12.sp, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ) + } + } + } + } +} + +@Composable +private fun StatusRow(trezorState: TrezorState) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_settings_dev), + contentDescription = null, + tint = Colors.White80, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Trezor", + color = Colors.White, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + when { + trezorState.isAutoReconnecting -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Colors.Brand + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Reconnecting...", color = Colors.White64, fontSize = 12.sp) + } + trezorState.isScanning -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Colors.Brand + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scanning...", color = Colors.White64, fontSize = 12.sp) + } + trezorState.isConnecting -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Colors.Brand + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Connecting...", color = Colors.White64, fontSize = 12.sp) + } + trezorState.connectedDevice != null -> { + StatusBadge(text = "Connected", color = Colors.Green) + } + trezorState.isInitialized -> { + StatusBadge(text = "Ready", color = Colors.Brand) + } + else -> { + StatusBadge(text = "Not initialized", color = Colors.White32) + } + } + } + } +} + +@Composable +private fun StatusBadge(text: String, color: androidx.compose.ui.graphics.Color) { + Text( + text = text, + color = color, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.15f)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) +} + +@Composable +private fun ActionButtonsRow( + trezorState: TrezorState, + onInitialize: () -> Unit, + onScan: () -> Unit, + onDisconnect: () -> Unit, + permissionsGranted: Boolean = true, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + if (trezorState.isAutoReconnecting) return@Row + if (!trezorState.isInitialized) { + PrimaryButton( + text = "Initialize", + onClick = onInitialize, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } else if (trezorState.connectedDevice != null) { + SecondaryButton( + text = "Disconnect", + onClick = onDisconnect, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = if (permissionsGranted) "Scan" else "Grant Permissions", + onClick = onScan, + enabled = !trezorState.isScanning, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } else { + PrimaryButton( + text = if (permissionsGranted) "Scan" else "Grant Permissions", + onClick = onScan, + enabled = !trezorState.isScanning, + size = ButtonSize.Small, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Preview +@Composable +private fun PreviewNotInitialized() { + AppThemeSurface { + TrezorContent( + trezorState = TrezorState(), + uiState = TrezorUiState(), + ) + } +} + +@Preview +@Composable +private fun PreviewInitialized() { + AppThemeSurface { + TrezorContent( + trezorState = TrezorState(isInitialized = true), + uiState = TrezorUiState(), + ) + } +} + +@Preview +@Composable +private fun PreviewWithDevices() { + val knownDevices = listOf( + KnownDevice( + id = "usb-1", + transportType = "usb", + name = "Trezor Safe 5", + path = "/dev/usb/001", + label = "My Savings", + model = "Safe 5", + lastConnectedAt = System.currentTimeMillis(), + ), + ) + val nearbyDevices = listOf( + TrezorDeviceInfo( + id = "ble-1", + transportType = TrezorTransportType.BLUETOOTH, + name = "Trezor Safe 7", + path = "AA:BB:CC:DD:EE:FF", + label = null, + model = "Safe 7", + isBootloader = false, + ), + ) + + AppThemeSurface { + TrezorContent( + trezorState = TrezorState( + isInitialized = true, + knownDevices = knownDevices, + nearbyDevices = nearbyDevices, + connectedDeviceId = "usb-1", + ), + uiState = TrezorUiState(), + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index 6e4467bb3..e346ae593 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -34,9 +35,11 @@ fun AdvancedSettingsScreen( navController: NavController, viewModel: AdvancedSettingsViewModel = hiltViewModel(), ) { + val isDevModeEnabled by viewModel.isDevModeEnabled.collectAsStateWithLifecycle() var showResetSuggestionsDialog by remember { mutableStateOf(false) } Content( + isDevModeEnabled = isDevModeEnabled, showResetSuggestionsDialog = showResetSuggestionsDialog, onBack = { navController.popBackStack() }, onCoinSelectionClick = { @@ -60,6 +63,9 @@ fun AdvancedSettingsScreen( onSweepFundsClick = { navController.navigate(Routes.SweepNav) }, + onTrezorClick = { + navController.navigate(Routes.Trezor) + }, onSuggestionsResetClick = { showResetSuggestionsDialog = true }, onResetSuggestionsDialogConfirm = { viewModel.resetSuggestions() @@ -72,6 +78,7 @@ fun AdvancedSettingsScreen( @Composable private fun Content( + isDevModeEnabled: Boolean = false, showResetSuggestionsDialog: Boolean, onBack: () -> Unit = {}, onCoinSelectionClick: () -> Unit = {}, @@ -81,6 +88,7 @@ private fun Content( onRgsServerClick: () -> Unit = {}, onAddressViewerClick: () -> Unit = {}, onSweepFundsClick: () -> Unit = {}, + onTrezorClick: () -> Unit = {}, onSuggestionsResetClick: () -> Unit = {}, onResetSuggestionsDialogConfirm: () -> Unit = {}, onResetSuggestionsDialogCancel: () -> Unit = {}, @@ -134,6 +142,17 @@ private fun Content( modifier = Modifier.testTag("RGSServer"), ) + // Hardware Wallet Section + if (isDevModeEnabled) { + SectionHeader(title = stringResource(R.string.settings__adv__section_hardware_wallet)) + + SettingsButtonRow( + title = stringResource(R.string.settings__adv__trezor), + onClick = onTrezorClick, + modifier = Modifier.testTag("Trezor"), + ) + } + // Other Section SectionHeader(title = stringResource(R.string.settings__adv__section_other)) diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt index 097b0f4cc..5d598f5f9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt @@ -3,6 +3,9 @@ package to.bitkit.ui.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import to.bitkit.data.SettingsStore import javax.inject.Inject @@ -12,6 +15,9 @@ class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, ) : ViewModel() { + val isDevModeEnabled = settingsStore.data.map { it.isDevModeEnabled } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + fun resetSuggestions() { viewModelScope.launch { settingsStore.update { it.copy(dismissedSuggestions = emptyList()) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt new file mode 100644 index 000000000..8f1da7e2f --- /dev/null +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -0,0 +1,301 @@ +package to.bitkit.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorTxInput +import com.synonym.bitkitcore.TrezorTxOutput +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import to.bitkit.di.BgDispatcher +import to.bitkit.models.Toast +import to.bitkit.repositories.KnownDevice +import to.bitkit.repositories.TrezorRepo +import to.bitkit.ui.shared.toast.ToastEventBus +import javax.inject.Inject + +data class TrezorUiState( + val addressIndex: Int = 0, + val derivationPath: String = "m/84'/0'/0'/0/0", + val messageToSign: String = "Hello, Trezor!", + val lastSignature: String? = null, + val lastSigningAddress: String? = null, + val isSigningMessage: Boolean = false, + val isGettingAddress: Boolean = false, + val isGettingPublicKey: Boolean = false, + val isVerifyingMessage: Boolean = false, +) + +@HiltViewModel +class TrezorViewModel @Inject constructor( + @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + private val trezorRepo: TrezorRepo, +) : ViewModel() { + + init { + trezorRepo.observeExternalDisconnects(viewModelScope) + } + + val trezorState = trezorRepo.state + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), trezorRepo.state.value) + + /** + * Flow indicating when a pairing code is needed. + * UI should show a dialog when this is true. + */ + val needsPairingCode = trezorRepo.needsPairingCode + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + + private val _uiState = MutableStateFlow(TrezorUiState()) + val uiState = _uiState.asStateFlow() + + fun hasKnownDevices(): Boolean = trezorRepo.hasKnownDevices() + + fun autoReconnect() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.autoReconnect() + .onSuccess { + val label = it.label ?: it.model ?: "Trezor" + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Reconnected to $label") + } + } + } + + fun initialize() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.initialize() + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Trezor initialized") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun scan() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.scan() + .onSuccess { devices -> + val count = devices.size + ToastEventBus.send( + type = Toast.ToastType.INFO, + title = "Found $count device${if (count != 1) "s" else ""}" + ) + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun connect(deviceId: String) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.connect(deviceId) + .onSuccess { features -> + val label = features.label ?: features.model ?: "Trezor" + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Connected to $label") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun connectKnownDevice(deviceId: String) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.connectKnownDevice(deviceId) + .onSuccess { features -> + val label = features.label ?: features.model ?: "Trezor" + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Connected to $label") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun forgetDevice(device: KnownDevice) { + viewModelScope.launch(bgDispatcher) { + val name = device.label ?: device.name ?: "device" + trezorRepo.forgetDevice(device.id) + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Forgot $name") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun getAddress(showOnTrezor: Boolean = false) { + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(isGettingAddress = true) } + val path = _uiState.value.derivationPath + trezorRepo.getAddress( + path = path, + showOnTrezor = showOnTrezor, + scriptType = TrezorScriptType.SPEND_WITNESS, + ) + .onSuccess { + _uiState.update { it.copy(isGettingAddress = false) } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Address generated") + } + .onFailure { + _uiState.update { it.copy(isGettingAddress = false) } + ToastEventBus.send(it) + } + } + } + + fun getPublicKey(showOnTrezor: Boolean = false) { + viewModelScope.launch(bgDispatcher) { + _uiState.update { it.copy(isGettingPublicKey = true) } + val path = _uiState.value.derivationPath + val accountPath = path.split("/").take(4).joinToString("/") + trezorRepo.getPublicKey( + path = accountPath, + showOnTrezor = showOnTrezor, + ) + .onSuccess { + _uiState.update { it.copy(isGettingPublicKey = false) } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Public key retrieved") + } + .onFailure { + _uiState.update { it.copy(isGettingPublicKey = false) } + ToastEventBus.send(it) + } + } + } + + fun setDerivationPath(path: String) { + _uiState.update { it.copy(derivationPath = path) } + } + + fun incrementAddressIndex() { + _uiState.update { state -> + val newIndex = state.addressIndex + 1 + state.copy( + addressIndex = newIndex, + derivationPath = "m/84'/0'/0'/0/$newIndex" + ) + } + } + + fun disconnect() { + viewModelScope.launch(bgDispatcher) { + trezorRepo.disconnect() + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Disconnected") + } + .onFailure { ToastEventBus.send(it) } + } + } + + fun setMessageToSign(message: String) { + _uiState.update { it.copy(messageToSign = message) } + } + + fun signMessage() { + viewModelScope.launch(bgDispatcher) { + val message = _uiState.value.messageToSign + if (message.isBlank()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Message cannot be empty") + return@launch + } + + _uiState.update { it.copy(isSigningMessage = true) } + val path = _uiState.value.derivationPath + trezorRepo.signMessage(path = path, message = message) + .onSuccess { response -> + _uiState.update { + it.copy( + lastSignature = response.signature, + lastSigningAddress = response.address, + isSigningMessage = false + ) + } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Message signed!") + } + .onFailure { e -> + _uiState.update { it.copy(isSigningMessage = false) } + ToastEventBus.send(e) + } + } + } + + fun verifyMessage() { + viewModelScope.launch(bgDispatcher) { + val signature = _uiState.value.lastSignature + val message = _uiState.value.messageToSign + val address = _uiState.value.lastSigningAddress + + if (signature == null || address == null) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Sign a message first") + return@launch + } + + _uiState.update { it.copy(isVerifyingMessage = true) } + trezorRepo.verifyMessage(address = address, signature = signature, message = message) + .onSuccess { isValid -> + _uiState.update { it.copy(isVerifyingMessage = false) } + val msg = if (isValid) "Signature is valid!" else "Signature is invalid" + val type = if (isValid) Toast.ToastType.SUCCESS else Toast.ToastType.ERROR + ToastEventBus.send(type = type, title = msg) + } + .onFailure { + _uiState.update { it.copy(isVerifyingMessage = false) } + ToastEventBus.send(it) + } + } + } + + fun clearError() { + trezorRepo.clearError() + } + + /** + * Submit the pairing code entered by the user. + */ + fun submitPairingCode(code: String) { + trezorRepo.submitPairingCode(code) + } + + /** + * Cancel pairing code entry. + */ + fun cancelPairingCode() { + trezorRepo.cancelPairingCode() + } + + /** + * Sign a Bitcoin transaction. + */ + fun signTx( + inputs: List, + outputs: List, + coin: String = "Bitcoin", + lockTime: UInt? = null, + version: UInt? = null, + ) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.signTx(inputs, outputs, coin, lockTime, version) + .onSuccess { signedTx -> + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = "Transaction signed (${signedTx.signatures.size} inputs)" + ) + } + .onFailure { ToastEventBus.send(it) } + } + } + + /** + * Clear stored pairing credentials for a device. + */ + fun clearCredentials(deviceId: String) { + viewModelScope.launch(bgDispatcher) { + trezorRepo.clearCredentials(deviceId) + .onSuccess { + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Credentials cleared") + } + .onFailure { ToastEventBus.send(it) } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d080f226..1488fc371 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -560,7 +560,9 @@ Networks Other Payments + Hardware Wallet Reset Suggestions + Trezor Advanced Connection Receipts Connections diff --git a/app/src/main/res/xml/usb_device_filter.xml b/app/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 000000000..ace761801 --- /dev/null +++ b/app/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,9 @@ + + + + + + + + + From 74de0cee9395aa12efb806c6d76ed1b3219afdaf Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 18 Feb 2026 10:24:00 -0500 Subject: [PATCH 02/48] fix: detekt warning --- .../java/to/bitkit/repositories/TrezorRepo.kt | 12 +- .../java/to/bitkit/services/BluetoothInit.kt | 1 + .../java/to/bitkit/services/TrezorService.kt | 1 + .../to/bitkit/services/TrezorTransport.kt | 242 ++++++++++++------ .../ui/screens/trezor/PublicKeySection.kt | 2 +- .../ui/screens/trezor/SignMessageSection.kt | 4 +- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 2 +- .../to/bitkit/viewmodels/TrezorViewModel.kt | 1 + 8 files changed, 173 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 6753bca90..7102ba38e 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -42,12 +42,18 @@ data class TrezorState( val error: String? = null, ) +@Suppress("TooManyFunctions") @Singleton class TrezorRepo @Inject constructor( @ApplicationContext private val context: Context, private val trezorService: TrezorService, private val trezorTransport: TrezorTransport, ) { + companion object { + private const val TAG = "TrezorRepo" + private const val KEY_KNOWN_DEVICES = "known_devices" + } + private val prefs by lazy { context.getSharedPreferences("trezor_device", Context.MODE_PRIVATE) } @@ -408,6 +414,7 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(error = e.message) } } + @Suppress("TooGenericExceptionCaught") private suspend fun connectWithThpRetry(deviceId: String): TrezorFeatures { TrezorDebugLog.log("THPRetry", "First connect attempt for: $deviceId") logCredentialFileState(deviceId, "BEFORE 1st attempt") @@ -446,11 +453,6 @@ class TrezorRepo @Inject constructor( val msg = e.message?.lowercase() ?: return false return "thp" in msg || "session" in msg || "timeout" in msg || "disconnect" in msg } - - companion object { - private const val TAG = "TrezorRepo" - private const val KEY_KNOWN_DEVICES = "known_devices" - } } @Serializable diff --git a/app/src/main/java/to/bitkit/services/BluetoothInit.kt b/app/src/main/java/to/bitkit/services/BluetoothInit.kt index d3f61f514..63053cfd1 100644 --- a/app/src/main/java/to/bitkit/services/BluetoothInit.kt +++ b/app/src/main/java/to/bitkit/services/BluetoothInit.kt @@ -40,6 +40,7 @@ object BluetoothInit { * @return true if initialization succeeded, false otherwise */ @Synchronized + @Suppress("TooGenericExceptionCaught") fun ensureInitialized(): Boolean { if (!initialized) { try { diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 32a2e77bd..87bc9ad18 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -33,6 +33,7 @@ import to.bitkit.async.ServiceQueue import javax.inject.Inject import javax.inject.Singleton +@Suppress("TooManyFunctions") @Singleton class TrezorService @Inject constructor( private val transport: TrezorTransport, diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index c186f6d47..7d9ee5ccf 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -1,6 +1,7 @@ package to.bitkit.services import android.annotation.SuppressLint +import android.app.PendingIntent import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothGatt @@ -13,7 +14,6 @@ import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings -import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -52,11 +52,50 @@ import javax.inject.Singleton * * USB communication uses 64-byte chunks, Bluetooth uses 244-byte chunks. */ +@Suppress("LargeClass") @Singleton class TrezorTransport @Inject constructor( @ApplicationContext private val context: Context, ) : TrezorTransportCallback { + companion object { + private const val TAG = "TrezorTransport" + private const val ACTION_USB_PERMISSION = "to.bitkit.USB_PERMISSION" + + // USB constants + private const val USB_CHUNK_SIZE = 64 + private const val USB_PERMISSION_TIMEOUT_MS = 60_000L + private const val TREZOR_VENDOR_ID_1 = 0x1209 + private const val TREZOR_VENDOR_ID_2 = 0x534c + + // BLE constants + private const val BLE_CHUNK_SIZE = 244 + private val SERVICE_UUID = UUID.fromString("8c000001-a59b-4d58-a9ad-073df69fa1b1") + private val WRITE_CHAR_UUID = UUID.fromString("8c000002-a59b-4d58-a9ad-073df69fa1b1") + private val NOTIFY_CHAR_UUID = UUID.fromString("8c000003-a59b-4d58-a9ad-073df69fa1b1") + private val PUSH_CHAR_UUID = UUID.fromString("8c000004-a59b-4d58-a9ad-073df69fa1b1") + private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + // Timeouts + private const val READ_TIMEOUT_MS = 5000 + private const val WRITE_TIMEOUT_MS = 5000 + private const val SCAN_DURATION_MS = 3000L + private const val CONNECTION_TIMEOUT_MS = 10000L + private const val BLE_READ_TIMEOUT_MS = 5000L + private const val DISCONNECT_TIMEOUT_MS = 3000L + private const val PAIRING_CODE_TIMEOUT_MS = 120000L // 2 minutes to enter code + + // BLE write retry settings + private const val BLE_WRITE_RETRY_COUNT = 3 + private const val BLE_WRITE_RETRY_DELAY_MS = 100L + private const val BLE_WRITE_INTER_DELAY_MS = 20L + private const val BLE_CONNECTION_STABILIZATION_MS = 1000L + + // BLE bonding constants + private const val MAX_BOND_POLL_ATTEMPTS = 60 + private const val BOND_POLL_INTERVAL_MS = 500L + } + private val usbManager: UsbManager by lazy { context.getSystemService(Context.USB_SERVICE) as UsbManager } @@ -78,6 +117,7 @@ class TrezorTransport @Inject constructor( @Volatile private var espMigrated = false + @Suppress("TooGenericExceptionCaught") private fun ensureEspMigration() { if (espMigrated) return synchronized(this) { @@ -103,7 +143,7 @@ class TrezorTransport @Inject constructor( Logger.info("Migrated $migrated THP credentials from SharedPreferences to files", context = TAG) } } catch (e: Exception) { - Logger.warn("ESP migration failed (may be inaccessible): ${e.message}", context = TAG) + Logger.warn("ESP migration failed (may be inaccessible)", e, context = TAG) } } } @@ -140,6 +180,7 @@ class TrezorTransport @Inject constructor( // ==================== TrezorTransportCallback Implementation ==================== + @Suppress("TooGenericExceptionCaught") override fun enumerateDevices(): List { val devices = mutableListOf() @@ -172,7 +213,8 @@ class TrezorTransport @Inject constructor( } Logger.info("Total enumerate found ${devices.size} Trezor device(s)", context = TAG) - TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: ${devices.map { "${it.path} (${it.transportType})" }}") + val summary = devices.map { "${it.path} (${it.transportType})" } + TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: $summary") return devices } @@ -225,7 +267,10 @@ class TrezorTransport @Inject constructor( ): TrezorCallMessageResult? { // For BLE/THP devices, the Rust side now handles THP protocol directly. // This callback returns null to let Rust use its built-in THP implementation. - Logger.debug("callMessage called for $path, type=$messageType - returning null (Rust handles THP)", context = TAG) + Logger.debug( + "callMessage called for $path, type=$messageType - returning null (Rust handles THP)", + context = TAG, + ) return null } @@ -312,6 +357,7 @@ class TrezorTransport @Inject constructor( submitPairingCode("") } + @Suppress("TooGenericExceptionCaught") override fun saveThpCredential(deviceId: String, credentialJson: String): Boolean { ensureEspMigration() return try { @@ -333,12 +379,18 @@ class TrezorTransport @Inject constructor( // Immediately verify the file was written val verifyExists = file.exists() val verifySize = if (verifyExists) file.length() else 0 - TrezorDebugLog.log("SAVE", "Wrote ${credentialJson.length} chars -> verify: exists=$verifyExists, size=$verifySize") + TrezorDebugLog.log( + "SAVE", + "Wrote ${credentialJson.length} chars -> verify: exists=$verifyExists, size=$verifySize", + ) if (!verifyExists || verifySize == 0L) { TrezorDebugLog.log("SAVE", "WARNING: File verification FAILED after write!") } - Logger.info("Saving THP credential to: ${file.absolutePath} (${credentialJson.length} chars)", context = TAG) + Logger.info( + "Saving THP credential to: ${file.absolutePath} (${credentialJson.length} chars)", + context = TAG, + ) true } catch (e: Exception) { TrezorDebugLog.log("SAVE", "EXCEPTION: ${e.message}") @@ -351,6 +403,7 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("RUST:$tag", message) } + @Suppress("TooGenericExceptionCaught") override fun loadThpCredential(deviceId: String): String? { ensureEspMigration() return try { @@ -390,6 +443,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") fun clearDeviceCredential(deviceId: String) { try { val file = credentialFile(deviceId) @@ -416,6 +470,7 @@ class TrezorTransport @Inject constructor( * This uses a BroadcastReceiver + CountDownLatch pattern because openDevice * runs on a background thread (Rust FFI callback), not the main thread. */ + @Suppress("TooGenericExceptionCaught") private fun requestUsbPermission(device: UsbDevice): Boolean { val latch = CountDownLatch(1) var granted = false @@ -453,13 +508,39 @@ class TrezorTransport @Inject constructor( return false } - Logger.info("USB permission ${if (granted) "granted" else "denied"} for ${device.deviceName}", context = TAG) + val status = if (granted) "granted" else "denied" + Logger.info("USB permission $status for ${device.deviceName}", context = TAG) return granted } finally { try { context.unregisterReceiver(receiver) } catch (_: Exception) {} } } + private data class UsbEndpoints(val read: UsbEndpoint, val write: UsbEndpoint) + + private fun findUsbEndpoints(usbInterface: UsbInterface): UsbEndpoints? { + var readEndpoint: UsbEndpoint? = null + var writeEndpoint: UsbEndpoint? = null + + for (i in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(i) + when { + endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + endpoint.direction == UsbConstants.USB_DIR_IN -> { + readEndpoint = endpoint + } + endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + endpoint.direction == UsbConstants.USB_DIR_OUT -> { + writeEndpoint = endpoint + } + } + } + + if (readEndpoint == null || writeEndpoint == null) return null + return UsbEndpoints(read = readEndpoint, write = writeEndpoint) + } + + @Suppress("TooGenericExceptionCaught", "ReturnCount") private fun openUsbDevice(path: String): TrezorTransportWriteResult { return try { // Close existing connection if any @@ -471,7 +552,10 @@ class TrezorTransport @Inject constructor( if (!usbManager.hasPermission(device)) { Logger.info("USB permission not yet granted, requesting...", context = TAG) if (!requestUsbPermission(device)) { - return TrezorTransportWriteResult(success = false, error = "USB permission denied for $path") + return TrezorTransportWriteResult( + success = false, + error = "USB permission denied for $path", + ) } } @@ -484,30 +568,22 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = false, error = "Failed to claim interface") } - var readEndpoint: UsbEndpoint? = null - var writeEndpoint: UsbEndpoint? = null - - for (i in 0 until usbInterface.endpointCount) { - val endpoint = usbInterface.getEndpoint(i) - when { - endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && - endpoint.direction == UsbConstants.USB_DIR_IN -> { - readEndpoint = endpoint - } - endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && - endpoint.direction == UsbConstants.USB_DIR_OUT -> { - writeEndpoint = endpoint - } - } - } - - if (readEndpoint == null || writeEndpoint == null) { + val endpoints = findUsbEndpoints(usbInterface) + if (endpoints == null) { connection.releaseInterface(usbInterface) connection.close() - return TrezorTransportWriteResult(success = false, error = "Could not find required endpoints") + return TrezorTransportWriteResult( + success = false, + error = "Could not find required endpoints", + ) } - usbConnections[path] = UsbOpenDevice(connection, usbInterface, readEndpoint, writeEndpoint) + usbConnections[path] = UsbOpenDevice( + connection, + usbInterface, + endpoints.read, + endpoints.write, + ) Logger.info("USB device opened: $path", context = TAG) TrezorTransportWriteResult(success = true, error = "") } catch (e: Exception) { @@ -516,6 +592,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun closeUsbDevice(path: String): TrezorTransportWriteResult { return try { val openDevice = usbConnections.remove(path) @@ -531,6 +608,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun readUsbChunk(path: String): TrezorTransportReadResult { return try { val openDevice = usbConnections[path] @@ -565,6 +643,7 @@ class TrezorTransport @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun writeUsbChunk(path: String, data: ByteArray): TrezorTransportWriteResult { return try { val openDevice = usbConnections[path] @@ -647,25 +726,18 @@ class TrezorTransport @Inject constructor( } @SuppressLint("MissingPermission") - private fun openBleDevice(path: String): TrezorTransportWriteResult { - val address = path.removePrefix("ble:") - val device = discoveredBleDevices[address] - ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") - - // Close existing connection - closeBleDevice(path) - - // Check if device needs bonding + private fun waitForBonding( + device: BluetoothDevice, + address: String, + ): TrezorTransportWriteResult? { if (device.bondState == BluetoothDevice.BOND_NONE) { Logger.info("Device not bonded, initiating bonding: $address", context = TAG) - val bondResult = device.createBond() - if (!bondResult) { + if (!device.createBond()) { return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") } - // Wait for bonding to complete var bondAttempts = 0 - while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < 60) { - Thread.sleep(500) + while (device.bondState != BluetoothDevice.BOND_BONDED && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { + Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ if (device.bondState == BluetoothDevice.BOND_NONE) { return TrezorTransportWriteResult(success = false, error = "Bonding failed or rejected") @@ -678,8 +750,8 @@ class TrezorTransport @Inject constructor( } else if (device.bondState == BluetoothDevice.BOND_BONDING) { Logger.info("Device is currently bonding, waiting: $address", context = TAG) var bondAttempts = 0 - while (device.bondState == BluetoothDevice.BOND_BONDING && bondAttempts < 60) { - Thread.sleep(500) + while (device.bondState == BluetoothDevice.BOND_BONDING && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { + Thread.sleep(BOND_POLL_INTERVAL_MS) bondAttempts++ } if (device.bondState != BluetoothDevice.BOND_BONDED) { @@ -688,6 +760,21 @@ class TrezorTransport @Inject constructor( } else { Logger.info("Device already bonded: $address", context = TAG) } + return null + } + + @SuppressLint("MissingPermission") + private fun openBleDevice(path: String): TrezorTransportWriteResult { + val address = path.removePrefix("ble:") + val device = discoveredBleDevices[address] + ?: return TrezorTransportWriteResult(success = false, error = "Device not found: $path") + + // Close existing connection + closeBleDevice(path) + + // Check if device needs bonding + val bondError = waitForBonding(device, address) + if (bondError != null) return bondError val connectionLatch = CountDownLatch(1) val gatt = device.connectGatt(context, false, bleGattCallback) @@ -729,6 +816,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = true, error = "") } + @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun closeBleDevice(path: String): TrezorTransportWriteResult { val connection = bleConnections.remove(path) @@ -757,6 +845,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = true, error = "") } + @Suppress("TooGenericExceptionCaught") private fun readBleChunk(path: String): TrezorTransportReadResult { val connection = bleConnections[path] ?: return TrezorTransportReadResult( @@ -781,6 +870,14 @@ class TrezorTransport @Inject constructor( } } + @Suppress( + "TooGenericExceptionCaught", + "CyclomaticComplexMethod", + "LongMethod", + "NestedBlockDepth", + "ReturnCount", + "LoopWithTooManyJumpStatements", + ) @SuppressLint("MissingPermission") private fun writeBleChunk(path: String, data: ByteArray): TrezorTransportWriteResult { val connection = bleConnections[path] @@ -810,11 +907,11 @@ class TrezorTransport @Inject constructor( if (!success) { // Get more diagnostic info val connState = connection.isConnected - val charProps = writeChar.properties + val charPropsHex = Integer.toHexString(writeChar.properties) Logger.warn( "BLE write initiation failed (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path, " + - "isConnected=$connState, charProps=0x${charProps.toString(16)}, dataLen=${data.size}", - context = TAG + "isConnected=$connState, charProps=0x$charPropsHex, dataLen=${data.size}", + context = TAG, ) if (attempt < BLE_WRITE_RETRY_COUNT) { Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) @@ -835,7 +932,11 @@ class TrezorTransport @Inject constructor( if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { lastError = "Write callback failed: ${connection.writeStatus}" - Logger.warn("BLE write callback failed with status ${connection.writeStatus} (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", context = TAG) + Logger.warn( + "BLE write callback failed with status ${connection.writeStatus} " + + "(attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", + context = TAG, + ) if (attempt < BLE_WRITE_RETRY_COUNT) { Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue @@ -1010,10 +1111,17 @@ class TrezorTransport @Inject constructor( Thread.sleep(200) + val charUuid = descriptor.characteristic.uuid if (status == BluetoothGatt.GATT_SUCCESS) { - Logger.info("CCCD descriptor write complete for ${descriptor.characteristic.uuid}: $path", context = TAG) + Logger.info( + "CCCD descriptor write complete for $charUuid: $path", + context = TAG, + ) } else { - Logger.warn("CCCD descriptor write failed with status $status for ${descriptor.characteristic.uuid}: $path", context = TAG) + Logger.warn( + "CCCD descriptor write failed with status $status for $charUuid: $path", + context = TAG, + ) } // If this was the TX characteristic CCCD, also enable PUSH CCCD @@ -1072,38 +1180,4 @@ class TrezorTransport @Inject constructor( usbConnections.keys.toList().forEach { path -> closeUsbDevice(path) } bleConnections.keys.toList().forEach { path -> closeBleDevice(path) } } - - companion object { - private const val TAG = "TrezorTransport" - private const val ACTION_USB_PERMISSION = "to.bitkit.USB_PERMISSION" - - // USB constants - private const val USB_CHUNK_SIZE = 64 - private const val USB_PERMISSION_TIMEOUT_MS = 60_000L - private const val TREZOR_VENDOR_ID_1 = 0x1209 - private const val TREZOR_VENDOR_ID_2 = 0x534c - - // BLE constants - private const val BLE_CHUNK_SIZE = 244 - private val SERVICE_UUID = UUID.fromString("8c000001-a59b-4d58-a9ad-073df69fa1b1") - private val WRITE_CHAR_UUID = UUID.fromString("8c000002-a59b-4d58-a9ad-073df69fa1b1") - private val NOTIFY_CHAR_UUID = UUID.fromString("8c000003-a59b-4d58-a9ad-073df69fa1b1") - private val PUSH_CHAR_UUID = UUID.fromString("8c000004-a59b-4d58-a9ad-073df69fa1b1") - private val CCCD_UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") - - // Timeouts - private const val READ_TIMEOUT_MS = 5000 - private const val WRITE_TIMEOUT_MS = 5000 - private const val SCAN_DURATION_MS = 3000L - private const val CONNECTION_TIMEOUT_MS = 10000L - private const val BLE_READ_TIMEOUT_MS = 5000L - private const val DISCONNECT_TIMEOUT_MS = 3000L - private const val PAIRING_CODE_TIMEOUT_MS = 120000L // 2 minutes to enter code - - // BLE write retry settings - private const val BLE_WRITE_RETRY_COUNT = 3 - private const val BLE_WRITE_RETRY_DELAY_MS = 100L - private const val BLE_WRITE_INTER_DELAY_MS = 20L - private const val BLE_CONNECTION_STABILIZATION_MS = 1000L - } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index b9a4bdade..1cac1fd1e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -2,6 +2,7 @@ package to.bitkit.ui.screens.trezor import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -23,7 +24,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.layout.Arrangement import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index edcd758eb..fda405957 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -75,7 +75,9 @@ internal fun SignMessageSection( PrimaryButton( text = if (uiState.isSigningMessage) "Signing..." else "Sign Message", onClick = onSignMessage, - enabled = !uiState.isSigningMessage && !uiState.isVerifyingMessage && uiState.messageToSign.isNotBlank(), + enabled = !uiState.isSigningMessage && + !uiState.isVerifyingMessage && + uiState.messageToSign.isNotBlank(), size = ButtonSize.Small, modifier = Modifier.weight(1f) ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 5117b1553..dd92d18d9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -54,6 +54,7 @@ import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState +import to.bitkit.services.TrezorDebugLog import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -64,7 +65,6 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.services.TrezorDebugLog import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard import to.bitkit.viewmodels.TrezorUiState diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt index 8f1da7e2f..6da87db8f 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -32,6 +32,7 @@ data class TrezorUiState( val isVerifyingMessage: Boolean = false, ) +@Suppress("TooManyFunctions") @HiltViewModel class TrezorViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, From 391242f3e812be3d8dae2c7d78c74d1429479737 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 18 Feb 2026 10:37:48 -0500 Subject: [PATCH 03/48] fix: bump bitkit-core to 0.1.39 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 029621f5c..d3f08a7a2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.38" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.39" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 89172b23dca844fa5c7619e4174b2e656cf6ccd9 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 25 Feb 2026 10:16:26 -0500 Subject: [PATCH 04/48] fix: add TrezorCoinType --- .../main/java/to/bitkit/repositories/TrezorRepo.kt | 11 ++++++----- .../main/java/to/bitkit/services/TrezorService.kt | 12 +++++++----- .../java/to/bitkit/viewmodels/TrezorViewModel.kt | 3 ++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 7102ba38e..fb154d69f 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -5,6 +5,7 @@ import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorPublicKeyResponse +import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx @@ -162,7 +163,7 @@ class TrezorRepo @Inject constructor( ensureConnected() val response = trezorService.getAddress( path = path, - coin = "Bitcoin", + coin = TrezorCoinType.BITCOIN, showOnTrezor = showOnTrezor, scriptType = scriptType, ) @@ -180,7 +181,7 @@ class TrezorRepo @Inject constructor( ensureConnected() val response = trezorService.getPublicKey( path = path, - coin = "Bitcoin", + coin = TrezorCoinType.BITCOIN, showOnTrezor = showOnTrezor, ) _state.update { it.copy(lastPublicKey = response, error = null) } @@ -211,7 +212,7 @@ class TrezorRepo @Inject constructor( val response = trezorService.signMessage( path = path, message = message, - coin = "Bitcoin", + coin = TrezorCoinType.BITCOIN, ) _state.update { it.copy(error = null) } response @@ -230,7 +231,7 @@ class TrezorRepo @Inject constructor( address = address, signature = signature, message = message, - coin = "Bitcoin", + coin = TrezorCoinType.BITCOIN, ) _state.update { it.copy(error = null) } result @@ -387,7 +388,7 @@ class TrezorRepo @Inject constructor( suspend fun signTx( inputs: List, outputs: List, - coin: String = "Bitcoin", + coin: TrezorCoinType = TrezorCoinType.BITCOIN, lockTime: UInt? = null, version: UInt? = null, ): Result = runCatching { diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 87bc9ad18..ca26a466c 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -1,5 +1,6 @@ package to.bitkit.services +import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures @@ -91,7 +92,7 @@ class TrezorService @Inject constructor( suspend fun getAddress( path: String, - coin: String? = "Bitcoin", + coin: TrezorCoinType? = TrezorCoinType.BITCOIN, showOnTrezor: Boolean = false, scriptType: TrezorScriptType? = null, ): TrezorAddressResponse { @@ -109,7 +110,7 @@ class TrezorService @Inject constructor( suspend fun getPublicKey( path: String, - coin: String? = "Bitcoin", + coin: TrezorCoinType? = TrezorCoinType.BITCOIN, showOnTrezor: Boolean = false, ): TrezorPublicKeyResponse { return ServiceQueue.CORE.background { @@ -138,7 +139,7 @@ class TrezorService @Inject constructor( suspend fun signMessage( path: String, message: String, - coin: String? = "Bitcoin", + coin: TrezorCoinType? = TrezorCoinType.BITCOIN, ): TrezorSignedMessageResponse { return ServiceQueue.CORE.background { trezorSignMessage( @@ -155,7 +156,7 @@ class TrezorService @Inject constructor( address: String, signature: String, message: String, - coin: String? = "Bitcoin", + coin: TrezorCoinType? = TrezorCoinType.BITCOIN, ): Boolean { return ServiceQueue.CORE.background { trezorVerifyMessage( @@ -172,7 +173,7 @@ class TrezorService @Inject constructor( suspend fun signTx( inputs: List, outputs: List, - coin: String? = "Bitcoin", + coin: TrezorCoinType? = TrezorCoinType.BITCOIN, lockTime: UInt? = null, version: UInt? = null, ): TrezorSignedTx { @@ -184,6 +185,7 @@ class TrezorService @Inject constructor( coin = coin, lockTime = lockTime, version = version, + prevTxs = emptyList(), ) ) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt index 6da87db8f..a0e7b9b94 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -3,6 +3,7 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import dagger.hilt.android.lifecycle.HiltViewModel @@ -271,7 +272,7 @@ class TrezorViewModel @Inject constructor( fun signTx( inputs: List, outputs: List, - coin: String = "Bitcoin", + coin: TrezorCoinType = TrezorCoinType.BITCOIN, lockTime: UInt? = null, version: UInt? = null, ) { From e413a7f22d271380f60c95033b7175ff907a3755 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Wed, 25 Feb 2026 10:17:28 -0500 Subject: [PATCH 05/48] chore: upgrade bitkit-core to v0.1.40 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d3f08a7a2..3b281e97b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.39" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.40" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 4cebd2fd1fe3b9075db69e7697a5ffc400a915f9 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 3 Mar 2026 13:37:51 -0500 Subject: [PATCH 06/48] feat: add network selector and balance lookup to trezor dashboard --- app/src/main/java/to/bitkit/models/Network.kt | 8 + .../java/to/bitkit/repositories/TrezorRepo.kt | 57 ++++- .../java/to/bitkit/services/TrezorService.kt | 34 +++ .../ui/screens/trezor/BalanceLookupSection.kt | 208 ++++++++++++++++++ .../bitkit/ui/screens/trezor/TrezorScreen.kt | 51 ++++- .../to/bitkit/viewmodels/TrezorViewModel.kt | 92 +++++++- gradle/libs.versions.toml | 2 +- 7 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt diff --git a/app/src/main/java/to/bitkit/models/Network.kt b/app/src/main/java/to/bitkit/models/Network.kt index cfb332bdc..f415aee84 100644 --- a/app/src/main/java/to/bitkit/models/Network.kt +++ b/app/src/main/java/to/bitkit/models/Network.kt @@ -1,6 +1,7 @@ package to.bitkit.models import com.synonym.bitkitcore.NetworkType +import com.synonym.bitkitcore.TrezorCoinType import org.lightningdevkit.ldknode.Network import com.synonym.bitkitcore.Network as BitkitCoreNetwork @@ -11,6 +12,13 @@ fun Network.networkUiText(): String = when (this) { Network.REGTEST -> "Regtest" } +fun Network.toTrezorCoinType(): TrezorCoinType = when (this) { + Network.BITCOIN -> TrezorCoinType.BITCOIN + Network.TESTNET -> TrezorCoinType.TESTNET + Network.SIGNET -> TrezorCoinType.SIGNET + Network.REGTEST -> TrezorCoinType.REGTEST +} + fun Network.toCoreNetwork(): BitkitCoreNetwork = when (this) { Network.BITCOIN -> BitkitCoreNetwork.BITCOIN Network.TESTNET -> BitkitCoreNetwork.TESTNET diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index fb154d69f..5795e08c7 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -1,11 +1,13 @@ package to.bitkit.repositories import android.content.Context +import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorPublicKeyResponse -import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx @@ -22,6 +24,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import to.bitkit.env.Env +import to.bitkit.models.toTrezorCoinType import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -159,11 +162,12 @@ class TrezorRepo @Inject constructor( path: String = "m/84'/0'/0'/0/0", showOnTrezor: Boolean = false, scriptType: TrezorScriptType? = TrezorScriptType.SPEND_WITNESS, + coin: TrezorCoinType = TrezorCoinType.BITCOIN, ): Result = runCatching { ensureConnected() val response = trezorService.getAddress( path = path, - coin = TrezorCoinType.BITCOIN, + coin = coin, showOnTrezor = showOnTrezor, scriptType = scriptType, ) @@ -177,11 +181,12 @@ class TrezorRepo @Inject constructor( suspend fun getPublicKey( path: String = "m/84'/0'/0'", showOnTrezor: Boolean = false, + coin: TrezorCoinType = TrezorCoinType.BITCOIN, ): Result = runCatching { ensureConnected() val response = trezorService.getPublicKey( path = path, - coin = TrezorCoinType.BITCOIN, + coin = coin, showOnTrezor = showOnTrezor, ) _state.update { it.copy(lastPublicKey = response, error = null) } @@ -191,6 +196,34 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(error = e.message) } } + suspend fun getAccountInfo( + extendedKey: String, + network: TrezorCoinType = Env.network.toTrezorCoinType(), + ): Result = runCatching { + trezorService.getAccountInfo( + extendedKey = extendedKey, + electrumUrl = electrumUrlForNetwork(network), + network = keyFormatNetwork(network), + ) + }.onFailure { e -> + Logger.error("Trezor getAccountInfo failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + + suspend fun getAddressInfo( + address: String, + network: TrezorCoinType = Env.network.toTrezorCoinType(), + ): Result = runCatching { + trezorService.getAddressInfo( + address = address, + electrumUrl = electrumUrlForNetwork(network), + network = keyFormatNetwork(network), + ) + }.onFailure { e -> + Logger.error("Trezor getAddressInfo failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + suspend fun disconnect(): Result = runCatching { TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") runCatching { trezorService.disconnect() } @@ -207,12 +240,13 @@ class TrezorRepo @Inject constructor( suspend fun signMessage( path: String = "m/84'/0'/0'/0/0", message: String, + coin: TrezorCoinType = TrezorCoinType.BITCOIN, ): Result = runCatching { ensureConnected() val response = trezorService.signMessage( path = path, message = message, - coin = TrezorCoinType.BITCOIN, + coin = coin, ) _state.update { it.copy(error = null) } response @@ -225,13 +259,14 @@ class TrezorRepo @Inject constructor( address: String, signature: String, message: String, + coin: TrezorCoinType = TrezorCoinType.BITCOIN, ): Result = runCatching { ensureConnected() val result = trezorService.verifyMessage( address = address, signature = signature, message = message, - coin = TrezorCoinType.BITCOIN, + coin = coin, ) _state.update { it.copy(error = null) } result @@ -370,6 +405,18 @@ class TrezorRepo @Inject constructor( }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } + private fun keyFormatNetwork(network: TrezorCoinType): TrezorCoinType = when (network) { + TrezorCoinType.REGTEST -> TrezorCoinType.TESTNET + else -> network + } + + private fun electrumUrlForNetwork(network: TrezorCoinType): String = when (network) { + TrezorCoinType.BITCOIN -> "ssl://bitkit.to:9999" + TrezorCoinType.TESTNET -> "ssl://electrum.blockstream.info:60002" + TrezorCoinType.REGTEST -> "ssl://electrs.bitkit.stag0.blocktank.to:9999" + TrezorCoinType.SIGNET -> "ssl://electrum.blockstream.info:60002" + } + private suspend fun ensureConnected() { if (trezorService.isConnected()) return val deviceId = _state.value.connectedDeviceId diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index ca26a466c..8e25af4cc 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -1,5 +1,7 @@ package to.bitkit.services +import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo @@ -15,6 +17,8 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import com.synonym.bitkitcore.TrezorVerifyMessageParams +import com.synonym.bitkitcore.trezorGetAccountInfo +import com.synonym.bitkitcore.trezorGetAddressInfo import com.synonym.bitkitcore.trezorClearCredentials import com.synonym.bitkitcore.trezorConnect import com.synonym.bitkitcore.trezorDisconnect @@ -196,4 +200,34 @@ class TrezorService @Inject constructor( trezorClearCredentials(deviceId = deviceId) } } + + suspend fun getAccountInfo( + extendedKey: String, + electrumUrl: String, + network: TrezorCoinType?, + gapLimit: UInt? = 20u, + ): AccountInfoResult { + return ServiceQueue.CORE.background { + trezorGetAccountInfo( + extendedKey = extendedKey, + electrumUrl = electrumUrl, + network = network, + gapLimit = gapLimit, + ) + } + } + + suspend fun getAddressInfo( + address: String, + electrumUrl: String, + network: TrezorCoinType?, + ): SingleAddressInfoResult { + return ServiceQueue.CORE.background { + trezorGetAddressInfo( + address = address, + electrumUrl = electrumUrl, + network = network, + ) + } + } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt new file mode 100644 index 000000000..f87228b59 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -0,0 +1,208 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.AccountUtxo +import com.synonym.bitkitcore.SingleAddressInfoResult +import to.bitkit.R +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.TrezorUiState + +@Composable +internal fun BalanceLookupSection( + uiState: TrezorUiState, + onInputChange: (String) -> Unit, + onLookup: () -> Unit, +) { + Column { + Text( + text = "Balance Lookup", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.lookupInput, + onValueChange = onInputChange, + label = { Text("Address or xpub", color = Colors.White50) }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + ), + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + PrimaryButton( + text = if (uiState.isLookingUp) "Looking up..." else "Lookup", + onClick = onLookup, + enabled = !uiState.isLookingUp && uiState.lookupInput.isNotBlank(), + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth(), + ) + + AnimatedVisibility(visible = uiState.accountInfoResult != null) { + uiState.accountInfoResult?.let { AccountInfoResultView(it) } + } + + AnimatedVisibility(visible = uiState.addressInfoResult != null) { + uiState.addressInfoResult?.let { AddressInfoResultView(it) } + } + } +} + +@Composable +private fun AccountInfoResultView(result: AccountInfoResult) { + Column { + Spacer(modifier = Modifier.height(12.dp)) + ResultCard { + InfoRow("Account Type", result.accountType.name) + InfoRow("Balance", "${result.balance} sats") + InfoRow("UTXO Count", "${result.utxoCount}") + InfoRow("Block Height", "${result.blockHeight}") + } + + if (result.account.utxo.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "UTXOs (${result.account.utxo.size})", + color = Colors.White64, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(4.dp)) + result.account.utxo.forEach { utxo -> + UtxoRow(utxo) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } +} + +@Composable +private fun AddressInfoResultView(result: SingleAddressInfoResult) { + val onCopyAddress = copyToClipboard(text = result.address, label = "Address") + Column { + Spacer(modifier = Modifier.height(12.dp)) + ResultCard { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = result.address, + color = Colors.Brand, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy address", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyAddress), + ) + } + InfoRow("Balance", "${result.balance} sats") + InfoRow("UTXOs", "${result.utxos.size}") + InfoRow("Transfers", "${result.transfers}") + InfoRow("Block Height", "${result.blockHeight}") + } + + if (result.utxos.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "UTXOs (${result.utxos.size})", + color = Colors.White64, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(4.dp)) + result.utxos.forEach { utxo -> + UtxoRow(utxo) + Spacer(modifier = Modifier.height(4.dp)) + } + } + } +} + +@Composable +private fun UtxoRow(utxo: AccountUtxo) { + val onCopyTxid = copyToClipboard(text = utxo.txid, label = "TXID") + ResultCard { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "${utxo.txid.take(8)}...${utxo.txid.takeLast(8)}:${utxo.vout}", + color = Colors.Brand, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy txid", + tint = Colors.Brand, + modifier = Modifier + .size(16.dp) + .clickableAlpha(onClick = onCopyTxid), + ) + } + InfoRow("Amount", "${utxo.amount} sats") + InfoRow("Confirmations", "${utxo.confirmations}") + InfoRow("Address", utxo.address) + } +} + +@Composable +private fun ResultCard(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Colors.White06) + .padding(12.dp), + ) { + content() + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index dd92d18d9..99820b01d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -49,6 +50,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R @@ -140,6 +142,9 @@ private fun TrezorScreenContent( onVerifyMessage = viewModel::verifyMessage, onMessageChange = viewModel::setMessageToSign, onClearError = viewModel::clearError, + onLookupInputChange = viewModel::setLookupInput, + onLookup = viewModel::lookupBalanceInfo, + onNetworkChange = viewModel::setSelectedNetwork, permissionsGranted = permissionsState.allPermissionsGranted, ) } @@ -162,15 +167,24 @@ private fun TrezorContent( onVerifyMessage: () -> Unit = {}, onMessageChange: (String) -> Unit = {}, onClearError: () -> Unit = {}, + onLookupInputChange: (String) -> Unit = {}, + onLookup: () -> Unit = {}, + onNetworkChange: (TrezorCoinType) -> Unit = {}, permissionsGranted: Boolean = true, ) { Column( modifier = Modifier .fillMaxWidth() + .imePadding() .verticalScroll(rememberScrollState()) ) { Text13Up("TREZOR TEST", color = Colors.White64) - VerticalSpacer(16.dp) + VerticalSpacer(8.dp) + NetworkSelectorRow( + selectedNetwork = uiState.selectedNetwork, + onNetworkChange = onNetworkChange, + ) + VerticalSpacer(12.dp) Card( colors = CardDefaults.cardColors(containerColor = Colors.White08), @@ -347,6 +361,14 @@ private fun TrezorContent( } } + // Balance Lookup (always visible, no device needed) + Spacer(modifier = Modifier.height(16.dp)) + BalanceLookupSection( + uiState = uiState, + onInputChange = onLookupInputChange, + onLookup = onLookup, + ) + // Debug Log Window DebugLogSection() } @@ -354,6 +376,33 @@ private fun TrezorContent( } } +@Composable +private fun NetworkSelectorRow( + selectedNetwork: TrezorCoinType, + onNetworkChange: (TrezorCoinType) -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + TrezorCoinType.entries.filter { it != TrezorCoinType.SIGNET }.forEach { network -> + val isSelected = network == selectedNetwork + val color = if (isSelected) Colors.Brand else Colors.White32 + Text( + text = network.name, + color = color, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.15f)) + .clickableAlpha(onClick = { onNetworkChange(network) }) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } +} + @Composable private fun DebugLogSection() { var expanded by remember { mutableStateOf(false) } diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt index a0e7b9b94..eaecb1772 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -2,8 +2,10 @@ package to.bitkit.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorCoinType +import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import dagger.hilt.android.lifecycle.HiltViewModel @@ -15,15 +17,19 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env import to.bitkit.models.Toast +import to.bitkit.models.toTrezorCoinType import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorRepo import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject data class TrezorUiState( + val selectedNetwork: TrezorCoinType = Env.network.toTrezorCoinType(), val addressIndex: Int = 0, - val derivationPath: String = "m/84'/0'/0'/0/0", + val derivationPath: String = + "m/84'/${if (Env.network.toTrezorCoinType() == TrezorCoinType.BITCOIN) "0" else "1"}'/0'/0/0", val messageToSign: String = "Hello, Trezor!", val lastSignature: String? = null, val lastSigningAddress: String? = null, @@ -31,6 +37,10 @@ data class TrezorUiState( val isGettingAddress: Boolean = false, val isGettingPublicKey: Boolean = false, val isVerifyingMessage: Boolean = false, + val lookupInput: String = "", + val isLookingUp: Boolean = false, + val accountInfoResult: AccountInfoResult? = null, + val addressInfoResult: SingleAddressInfoResult? = null, ) @Suppress("TooManyFunctions") @@ -129,11 +139,12 @@ class TrezorViewModel @Inject constructor( fun getAddress(showOnTrezor: Boolean = false) { viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(isGettingAddress = true) } - val path = _uiState.value.derivationPath + val state = _uiState.value trezorRepo.getAddress( - path = path, + path = state.derivationPath, showOnTrezor = showOnTrezor, scriptType = TrezorScriptType.SPEND_WITNESS, + coin = state.selectedNetwork, ) .onSuccess { _uiState.update { it.copy(isGettingAddress = false) } @@ -149,11 +160,12 @@ class TrezorViewModel @Inject constructor( fun getPublicKey(showOnTrezor: Boolean = false) { viewModelScope.launch(bgDispatcher) { _uiState.update { it.copy(isGettingPublicKey = true) } - val path = _uiState.value.derivationPath - val accountPath = path.split("/").take(4).joinToString("/") + val state = _uiState.value + val accountPath = state.derivationPath.split("/").take(4).joinToString("/") trezorRepo.getPublicKey( path = accountPath, showOnTrezor = showOnTrezor, + coin = state.selectedNetwork, ) .onSuccess { _uiState.update { it.copy(isGettingPublicKey = false) } @@ -170,12 +182,24 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(derivationPath = path) } } + fun setSelectedNetwork(network: TrezorCoinType) { + val coinType = if (network == TrezorCoinType.BITCOIN) "0" else "1" + _uiState.update { + it.copy( + selectedNetwork = network, + addressIndex = 0, + derivationPath = "m/84'/$coinType'/0'/0/0", + ) + } + } + fun incrementAddressIndex() { _uiState.update { state -> val newIndex = state.addressIndex + 1 + val coinType = if (state.selectedNetwork == TrezorCoinType.BITCOIN) "0" else "1" state.copy( addressIndex = newIndex, - derivationPath = "m/84'/0'/0'/0/$newIndex" + derivationPath = "m/84'/$coinType'/0'/0/$newIndex", ) } } @@ -194,6 +218,49 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(messageToSign = message) } } + fun setLookupInput(input: String) { + _uiState.update { it.copy(lookupInput = input) } + } + + fun lookupBalanceInfo() { + viewModelScope.launch(bgDispatcher) { + val input = _uiState.value.lookupInput.trim() + if (input.isBlank()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter an address or xpub") + return@launch + } + _uiState.update { it.copy(isLookingUp = true, accountInfoResult = null, addressInfoResult = null) } + + val network = _uiState.value.selectedNetwork + if (isExtendedKey(input)) { + trezorRepo.getAccountInfo(extendedKey = input, network = network) + .onSuccess { result -> + _uiState.update { it.copy(isLookingUp = false, accountInfoResult = result) } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Account info retrieved") + } + .onFailure { + _uiState.update { it.copy(isLookingUp = false) } + ToastEventBus.send(it) + } + } else { + trezorRepo.getAddressInfo(address = input, network = network) + .onSuccess { result -> + _uiState.update { it.copy(isLookingUp = false, addressInfoResult = result) } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Address info retrieved") + } + .onFailure { + _uiState.update { it.copy(isLookingUp = false) } + ToastEventBus.send(it) + } + } + } + } + + private fun isExtendedKey(input: String): Boolean { + val prefixes = listOf("xpub", "ypub", "zpub", "tpub", "upub", "vpub") + return prefixes.any { input.lowercase().startsWith(it) } + } + fun signMessage() { viewModelScope.launch(bgDispatcher) { val message = _uiState.value.messageToSign @@ -203,8 +270,8 @@ class TrezorViewModel @Inject constructor( } _uiState.update { it.copy(isSigningMessage = true) } - val path = _uiState.value.derivationPath - trezorRepo.signMessage(path = path, message = message) + val state = _uiState.value + trezorRepo.signMessage(path = state.derivationPath, message = message, coin = state.selectedNetwork) .onSuccess { response -> _uiState.update { it.copy( @@ -234,7 +301,12 @@ class TrezorViewModel @Inject constructor( } _uiState.update { it.copy(isVerifyingMessage = true) } - trezorRepo.verifyMessage(address = address, signature = signature, message = message) + trezorRepo.verifyMessage( + address = address, + signature = signature, + message = message, + coin = _uiState.value.selectedNetwork, + ) .onSuccess { isValid -> _uiState.update { it.copy(isVerifyingMessage = false) } val msg = if (isValid) "Signature is valid!" else "Signature is invalid" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fd301c0a9..c7a6ad529 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.40" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.42" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 8986b335961e365f23bf8cb630ee65ce1580fb1e Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Thu, 5 Mar 2026 12:51:03 -0500 Subject: [PATCH 07/48] feat: add tx compose, sign, and broadcast to trezor dashboard --- .../java/to/bitkit/repositories/TrezorRepo.kt | 73 +++++ .../java/to/bitkit/services/TrezorService.kt | 55 +++- app/src/main/java/to/bitkit/ui/ContentView.kt | 3 +- .../ui/screens/trezor/BalanceLookupSection.kt | 68 +++- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 35 +- .../ui/settings/AdvancedSettingsScreen.kt | 2 +- .../to/bitkit/viewmodels/TrezorViewModel.kt | 309 +++++++++++++++++- 7 files changed, 534 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 5795e08c7..a3013f89a 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -7,8 +7,14 @@ import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorPrecomposeParams +import com.synonym.bitkitcore.TrezorPrecomposedInput +import com.synonym.bitkitcore.TrezorPrecomposedOutput +import com.synonym.bitkitcore.TrezorPrecomposedResult +import com.synonym.bitkitcore.TrezorPrevTx import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorSignTxParams import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTxInput @@ -224,6 +230,73 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(error = e.message) } } + suspend fun precomposeTransaction( + params: TrezorPrecomposeParams, + ): Result> = runCatching { + trezorService.precomposeTransaction(params = params) + }.onFailure { + Logger.error("Trezor precomposeTransaction failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + + suspend fun convertToSignParams( + inputs: List, + outputs: List, + coin: TrezorCoinType?, + ): Result = runCatching { + trezorService.precomposedToSignParams( + inputs = inputs, + outputs = outputs, + coin = coin, + ) + }.onFailure { + Logger.error("Trezor convertToSignParams failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + + suspend fun fetchPrevTxs( + txids: List, + network: TrezorCoinType, + ): Result> = runCatching { + trezorService.fetchPrevTxs( + txids = txids, + electrumUrl = electrumUrlForNetwork(network), + ) + }.onFailure { + Logger.error("Trezor fetchPrevTxs failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + + suspend fun broadcastRawTx( + serializedTx: String, + network: TrezorCoinType, + ): Result = runCatching { + trezorService.broadcastRawTx( + serializedTx = serializedTx, + electrumUrl = electrumUrlForNetwork(network), + ) + }.onFailure { + Logger.error("Trezor broadcastRawTx failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + + suspend fun signTxWithParams(params: TrezorSignTxParams): Result = runCatching { + ensureConnected() + val response = trezorService.signTxWithParams(params) + _state.update { it.copy(error = null) } + response + }.onFailure { + Logger.error("Trezor signTxWithParams failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } + + fun coinStringForNetwork(network: TrezorCoinType): String = when (network) { + TrezorCoinType.BITCOIN -> "Bitcoin" + TrezorCoinType.TESTNET -> "Testnet" + TrezorCoinType.REGTEST -> "Regtest" + TrezorCoinType.SIGNET -> "Testnet" + } + suspend fun disconnect(): Result = runCatching { TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") runCatching { trezorService.disconnect() } diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 8e25af4cc..07e520c57 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -2,12 +2,17 @@ package to.bitkit.services import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.SingleAddressInfoResult -import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorGetAddressParams import com.synonym.bitkitcore.TrezorGetPublicKeyParams +import com.synonym.bitkitcore.TrezorPrecomposeParams +import com.synonym.bitkitcore.TrezorPrecomposedInput +import com.synonym.bitkitcore.TrezorPrecomposedOutput +import com.synonym.bitkitcore.TrezorPrecomposedResult +import com.synonym.bitkitcore.TrezorPrevTx import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignMessageParams @@ -17,18 +22,22 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import com.synonym.bitkitcore.TrezorVerifyMessageParams -import com.synonym.bitkitcore.trezorGetAccountInfo -import com.synonym.bitkitcore.trezorGetAddressInfo import com.synonym.bitkitcore.trezorClearCredentials import com.synonym.bitkitcore.trezorConnect import com.synonym.bitkitcore.trezorDisconnect +import com.synonym.bitkitcore.trezorBroadcastRawTx +import com.synonym.bitkitcore.trezorFetchPrevTxs +import com.synonym.bitkitcore.trezorGetAccountInfo import com.synonym.bitkitcore.trezorGetAddress +import com.synonym.bitkitcore.trezorGetAddressInfo import com.synonym.bitkitcore.trezorGetConnectedDevice import com.synonym.bitkitcore.trezorGetPublicKey import com.synonym.bitkitcore.trezorInitialize import com.synonym.bitkitcore.trezorIsConnected import com.synonym.bitkitcore.trezorIsInitialized import com.synonym.bitkitcore.trezorListDevices +import com.synonym.bitkitcore.trezorPrecomposeTransaction +import com.synonym.bitkitcore.trezorPrecomposedToSignParams import com.synonym.bitkitcore.trezorScan import com.synonym.bitkitcore.trezorSetTransportCallback import com.synonym.bitkitcore.trezorSignMessage @@ -201,6 +210,46 @@ class TrezorService @Inject constructor( } } + suspend fun precomposeTransaction( + params: TrezorPrecomposeParams, + ): List { + return ServiceQueue.CORE.background { + trezorPrecomposeTransaction(params = params) + } + } + + suspend fun precomposedToSignParams( + inputs: List, + outputs: List, + coin: TrezorCoinType?, + ): TrezorSignTxParams { + return ServiceQueue.CORE.background { + trezorPrecomposedToSignParams( + inputs = inputs, + outputs = outputs, + coin = coin, + ) + } + } + + suspend fun signTxWithParams(params: TrezorSignTxParams): TrezorSignedTx { + return ServiceQueue.CORE.background { + trezorSignTx(params = params) + } + } + + suspend fun fetchPrevTxs(txids: List, electrumUrl: String): List { + return ServiceQueue.CORE.background { + trezorFetchPrevTxs(txids = txids, electrumUrl = electrumUrl) + } + } + + suspend fun broadcastRawTx(serializedTx: String, electrumUrl: String): String { + return ServiceQueue.CORE.background { + trezorBroadcastRawTx(serializedTx = serializedTx, electrumUrl = electrumUrl) + } + } + suspend fun getAccountInfo( extendedKey: String, electrumUrl: String, diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 3173e32db..f1d2c5f16 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -65,7 +65,6 @@ import to.bitkit.ui.screens.profile.ProfileIntroScreen import to.bitkit.ui.screens.recovery.RecoveryMnemonicScreen import to.bitkit.ui.screens.recovery.RecoveryModeScreen import to.bitkit.ui.screens.scanner.QrScanningScreen -import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.scanner.SCAN_REQUEST_KEY import to.bitkit.ui.screens.settings.DevSettingsScreen import to.bitkit.ui.screens.settings.FeeSettingsScreen @@ -95,6 +94,7 @@ import to.bitkit.ui.screens.transfer.external.ExternalConnectionScreen import to.bitkit.ui.screens.transfer.external.ExternalNodeViewModel import to.bitkit.ui.screens.transfer.external.ExternalSuccessScreen import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen +import to.bitkit.ui.screens.trezor.TrezorScreen import to.bitkit.ui.screens.wallets.HomeScreen import to.bitkit.ui.screens.wallets.SavingsWalletScreen import to.bitkit.ui.screens.wallets.SpendingWalletScreen @@ -1758,7 +1758,6 @@ sealed interface Routes { @Serializable data object Trezor : Routes - @Serializable data object SweepNav : Routes diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index f87228b59..e5d791c31 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.sp import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountUtxo import com.synonym.bitkitcore.SingleAddressInfoResult +import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.PrimaryButton @@ -35,11 +36,23 @@ import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard import to.bitkit.viewmodels.TrezorUiState +@Suppress("LongParameterList") @Composable internal fun BalanceLookupSection( uiState: TrezorUiState, + isDeviceConnected: Boolean, onInputChange: (String) -> Unit, onLookup: () -> Unit, + onSendAddressChange: (String) -> Unit, + onSendAmountChange: (String) -> Unit, + onSendFeeRateChange: (String) -> Unit, + onToggleSendMax: () -> Unit, + onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCompose: () -> Unit, + onSign: () -> Unit, + onBroadcast: () -> Unit, + onBackToForm: () -> Unit, + onResetSend: () -> Unit, ) { Column { Text( @@ -76,7 +89,23 @@ internal fun BalanceLookupSection( ) AnimatedVisibility(visible = uiState.accountInfoResult != null) { - uiState.accountInfoResult?.let { AccountInfoResultView(it) } + uiState.accountInfoResult?.let { + AccountInfoResultView( + result = it, + uiState = uiState, + isDeviceConnected = isDeviceConnected, + onSendAddressChange = onSendAddressChange, + onSendAmountChange = onSendAmountChange, + onSendFeeRateChange = onSendFeeRateChange, + onToggleSendMax = onToggleSendMax, + onSortingStrategyChange = onSortingStrategyChange, + onCompose = onCompose, + onSign = onSign, + onBroadcast = onBroadcast, + onBackToForm = onBackToForm, + onResetSend = onResetSend, + ) + } } AnimatedVisibility(visible = uiState.addressInfoResult != null) { @@ -85,8 +114,23 @@ internal fun BalanceLookupSection( } } +@Suppress("LongParameterList") @Composable -private fun AccountInfoResultView(result: AccountInfoResult) { +private fun AccountInfoResultView( + result: AccountInfoResult, + uiState: TrezorUiState, + isDeviceConnected: Boolean, + onSendAddressChange: (String) -> Unit, + onSendAmountChange: (String) -> Unit, + onSendFeeRateChange: (String) -> Unit, + onToggleSendMax: () -> Unit, + onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCompose: () -> Unit, + onSign: () -> Unit, + onBroadcast: () -> Unit, + onBackToForm: () -> Unit, + onResetSend: () -> Unit, +) { Column { Spacer(modifier = Modifier.height(12.dp)) ResultCard { @@ -110,6 +154,24 @@ private fun AccountInfoResultView(result: AccountInfoResult) { Spacer(modifier = Modifier.height(4.dp)) } } + + if (result.balance > 0uL) { + Spacer(modifier = Modifier.height(16.dp)) + SendTransactionSection( + uiState = uiState, + isDeviceConnected = isDeviceConnected, + onAddressChange = onSendAddressChange, + onAmountChange = onSendAmountChange, + onFeeRateChange = onSendFeeRateChange, + onToggleSendMax = onToggleSendMax, + onSortingStrategyChange = onSortingStrategyChange, + onCompose = onCompose, + onSign = onSign, + onBroadcast = onBroadcast, + onBack = onBackToForm, + onReset = onResetSend, + ) + } } } @@ -195,7 +257,7 @@ private fun UtxoRow(utxo: AccountUtxo) { } @Composable -private fun ResultCard(content: @Composable () -> Unit) { +internal fun ResultCard(content: @Composable () -> Unit) { Column( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 99820b01d..ad72022d5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -9,13 +9,13 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -52,6 +52,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorSortingStrategy import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R import to.bitkit.repositories.KnownDevice @@ -145,11 +146,22 @@ private fun TrezorScreenContent( onLookupInputChange = viewModel::setLookupInput, onLookup = viewModel::lookupBalanceInfo, onNetworkChange = viewModel::setSelectedNetwork, + onSendAddressChange = viewModel::setSendAddress, + onSendAmountChange = viewModel::setSendAmount, + onSendFeeRateChange = viewModel::setSendFeeRate, + onToggleSendMax = viewModel::toggleSendMax, + onSortingStrategyChange = viewModel::setSortingStrategy, + onCompose = viewModel::composeTx, + onSign = viewModel::signComposedTx, + onBroadcast = viewModel::broadcastSignedTx, + onBackToForm = viewModel::backToComposeForm, + onResetSend = viewModel::resetSendFlow, permissionsGranted = permissionsState.allPermissionsGranted, ) } } +@Suppress("LongParameterList") @Composable private fun TrezorContent( trezorState: TrezorState, @@ -170,6 +182,16 @@ private fun TrezorContent( onLookupInputChange: (String) -> Unit = {}, onLookup: () -> Unit = {}, onNetworkChange: (TrezorCoinType) -> Unit = {}, + onSendAddressChange: (String) -> Unit = {}, + onSendAmountChange: (String) -> Unit = {}, + onSendFeeRateChange: (String) -> Unit = {}, + onToggleSendMax: () -> Unit = {}, + onSortingStrategyChange: (TrezorSortingStrategy) -> Unit = {}, + onCompose: () -> Unit = {}, + onSign: () -> Unit = {}, + onBroadcast: () -> Unit = {}, + onBackToForm: () -> Unit = {}, + onResetSend: () -> Unit = {}, permissionsGranted: Boolean = true, ) { Column( @@ -365,8 +387,19 @@ private fun TrezorContent( Spacer(modifier = Modifier.height(16.dp)) BalanceLookupSection( uiState = uiState, + isDeviceConnected = trezorState.connectedDevice != null, onInputChange = onLookupInputChange, onLookup = onLookup, + onSendAddressChange = onSendAddressChange, + onSendAmountChange = onSendAmountChange, + onSendFeeRateChange = onSendFeeRateChange, + onToggleSendMax = onToggleSendMax, + onSortingStrategyChange = onSortingStrategyChange, + onCompose = onCompose, + onSign = onSign, + onBroadcast = onBroadcast, + onBackToForm = onBackToForm, + onResetSend = onResetSend, ) // Debug Log Window diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index e346ae593..d8786443f 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -10,13 +10,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R import to.bitkit.ui.Routes diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt index eaecb1772..493d61a9e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt @@ -5,7 +5,14 @@ import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorCoinType +import com.synonym.bitkitcore.TrezorFeeLevel +import com.synonym.bitkitcore.TrezorPrecomposeOutput +import com.synonym.bitkitcore.TrezorPrecomposeParams +import com.synonym.bitkitcore.TrezorPrecomposedResult import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorSignTxParams +import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorSortingStrategy import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,6 +29,7 @@ import to.bitkit.models.Toast import to.bitkit.models.toTrezorCoinType import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorRepo +import to.bitkit.services.TrezorDebugLog import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject @@ -41,8 +49,22 @@ data class TrezorUiState( val isLookingUp: Boolean = false, val accountInfoResult: AccountInfoResult? = null, val addressInfoResult: SingleAddressInfoResult? = null, + val sendAddress: String = "", + val sendAmountSats: String = "", + val sendFeeRate: String = "2", + val isSendMax: Boolean = false, + val isComposing: Boolean = false, + val isSigning: Boolean = false, + val precomposedResult: TrezorPrecomposedResult.Final? = null, + val signedTxResult: TrezorSignedTx? = null, + val sendStep: SendStep = SendStep.FORM, + val sortingStrategy: TrezorSortingStrategy = TrezorSortingStrategy.BIP69, + val isBroadcasting: Boolean = false, + val broadcastTxid: String? = null, ) +enum class SendStep { FORM, REVIEW, SIGNED } + @Suppress("TooManyFunctions") @HiltViewModel class TrezorViewModel @Inject constructor( @@ -229,7 +251,25 @@ class TrezorViewModel @Inject constructor( ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter an address or xpub") return@launch } - _uiState.update { it.copy(isLookingUp = true, accountInfoResult = null, addressInfoResult = null) } + _uiState.update { + it.copy( + isLookingUp = true, + accountInfoResult = null, + addressInfoResult = null, + sendAddress = "", + sendAmountSats = "", + sendFeeRate = "2", + isSendMax = false, + isComposing = false, + isSigning = false, + precomposedResult = null, + signedTxResult = null, + sendStep = SendStep.FORM, + sortingStrategy = TrezorSortingStrategy.BIP69, + isBroadcasting = false, + broadcastTxid = null, + ) + } val network = _uiState.value.selectedNetwork if (isExtendedKey(input)) { @@ -320,6 +360,273 @@ class TrezorViewModel @Inject constructor( } } + fun setSendAddress(address: String) { + _uiState.update { it.copy(sendAddress = address) } + } + + fun setSendAmount(amount: String) { + _uiState.update { it.copy(sendAmountSats = amount) } + } + + fun setSendFeeRate(feeRate: String) { + _uiState.update { it.copy(sendFeeRate = feeRate) } + } + + fun toggleSendMax() { + _uiState.update { it.copy(isSendMax = !it.isSendMax) } + } + + fun setSortingStrategy(strategy: TrezorSortingStrategy) { + _uiState.update { it.copy(sortingStrategy = strategy) } + } + + fun broadcastSignedTx() { + viewModelScope.launch(bgDispatcher) { + val state = _uiState.value + val rawTx = state.signedTxResult?.serializedTx ?: return@launch + _uiState.update { it.copy(isBroadcasting = true) } + trezorRepo.broadcastRawTx(serializedTx = rawTx, network = state.selectedNetwork) + .onSuccess { txid -> + TrezorDebugLog.log("BROADCAST", "SUCCESS txid=$txid") + _uiState.update { it.copy(isBroadcasting = false, broadcastTxid = txid) } + ToastEventBus.send(type = Toast.ToastType.SUCCESS, title = "Transaction broadcast") + } + .onFailure { + TrezorDebugLog.log("BROADCAST", "FAILED: ${it.message}") + _uiState.update { it.copy(isBroadcasting = false) } + ToastEventBus.send(it) + } + } + } + + fun resetSendFlow() { + _uiState.update { + it.copy( + sendAddress = "", + sendAmountSats = "", + sendFeeRate = "2", + isSendMax = false, + isComposing = false, + isSigning = false, + precomposedResult = null, + signedTxResult = null, + sendStep = SendStep.FORM, + sortingStrategy = TrezorSortingStrategy.BIP69, + isBroadcasting = false, + broadcastTxid = null, + ) + } + } + + fun backToComposeForm() { + _uiState.update { + it.copy( + sendStep = SendStep.FORM, + precomposedResult = null, + signedTxResult = null, + ) + } + } + + fun composeTx() { + viewModelScope.launch(bgDispatcher) { + val state = _uiState.value + val accountInfo = state.accountInfoResult ?: return@launch + if (!validateComposeInputs(state)) return@launch + + _uiState.update { it.copy(isComposing = true) } + + val coinStr = trezorRepo.coinStringForNetwork(state.selectedNetwork) + TrezorDebugLog.log("COMPOSE", "=== composeTx START ===") + TrezorDebugLog.log("COMPOSE", "address=${state.sendAddress}") + TrezorDebugLog.log("COMPOSE", "amount=${state.sendAmountSats}, sendMax=${state.isSendMax}") + TrezorDebugLog.log("COMPOSE", "feeRate=${state.sendFeeRate} sat/vB, coin=$coinStr") + TrezorDebugLog.log("COMPOSE", "account.path=${accountInfo.account.path}") + TrezorDebugLog.log("COMPOSE", "utxos=${accountInfo.account.utxo.size}, balance=${accountInfo.balance}") + + val output = if (state.isSendMax) { + TrezorPrecomposeOutput.SendMax(address = state.sendAddress) + } else { + TrezorPrecomposeOutput.Payment(address = state.sendAddress, amount = state.sendAmountSats) + } + + val params = TrezorPrecomposeParams( + outputs = listOf(output), + coin = coinStr, + account = accountInfo.account, + feeLevels = listOf( + TrezorFeeLevel(feePerUnit = state.sendFeeRate, baseFee = null, floorBaseFee = null) + ), + sequence = null, + sortingStrategy = state.sortingStrategy, + ) + + trezorRepo.precomposeTransaction(params) + .onSuccess { handlePrecomposeResults(it) } + .onFailure { + TrezorDebugLog.log("COMPOSE", "FAILED: ${it.message}") + _uiState.update { it.copy(isComposing = false) } + ToastEventBus.send(it) + } + } + } + + private suspend fun validateComposeInputs(state: TrezorUiState): Boolean { + if (state.sendAddress.isBlank()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter a destination address") + return false + } + if (!state.isSendMax && state.sendAmountSats.isBlank()) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter an amount") + return false + } + val feeRate = state.sendFeeRate.toLongOrNull() + if (feeRate == null || feeRate <= 0) { + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter a valid fee rate") + return false + } + return true + } + + private suspend fun handlePrecomposeResults(results: List) { + TrezorDebugLog.log("COMPOSE", "Got ${results.size} result(s)") + results.forEachIndexed { i, r -> + when (r) { + is TrezorPrecomposedResult.Final -> TrezorDebugLog.log( + "COMPOSE", + "[$i] Final: fee=${r.fee}, totalSpent=${r.totalSpent}, " + + "feePerByte=${r.feePerByte}, bytes=${r.bytes}, " + + "inputs=${r.inputs.size}, outputs=${r.outputs.size}" + ) + is TrezorPrecomposedResult.NonFinal -> TrezorDebugLog.log( + "COMPOSE", + "[$i] NonFinal: max=${r.max}, fee=${r.fee}" + ) + is TrezorPrecomposedResult.Error -> TrezorDebugLog.log( + "COMPOSE", + "[$i] Error: ${r.error}" + ) + } + } + val finalResult = results.filterIsInstance().firstOrNull() + val errorResult = results.filterIsInstance().firstOrNull() + if (finalResult != null) { + finalResult.inputs.forEach { + TrezorDebugLog.log( + "COMPOSE", + " input: txid=${it.txid}, vout=${it.vout}, " + + "amount=${it.amount}, path=${it.path}, scriptType=${it.scriptType}" + ) + } + finalResult.outputs.forEach { + when (it) { + is com.synonym.bitkitcore.TrezorPrecomposedOutput.Payment -> + TrezorDebugLog.log("COMPOSE", " output(payment): addr=${it.address}, amount=${it.amount}") + is com.synonym.bitkitcore.TrezorPrecomposedOutput.Change -> + TrezorDebugLog.log( + "COMPOSE", + " output(change): addr=${it.address}, " + + "amount=${it.amount}, path=${it.path}" + ) + is com.synonym.bitkitcore.TrezorPrecomposedOutput.OpReturn -> + TrezorDebugLog.log("COMPOSE", " output(opreturn): ${it.dataHex}") + } + } + TrezorDebugLog.log("COMPOSE", "=== composeTx SUCCESS ===") + _uiState.update { + it.copy(isComposing = false, precomposedResult = finalResult, sendStep = SendStep.REVIEW) + } + ToastEventBus.send(type = Toast.ToastType.INFO, title = "Transaction composed") + } else if (errorResult != null) { + TrezorDebugLog.log("COMPOSE", "=== composeTx FAILED (compose error) ===") + _uiState.update { it.copy(isComposing = false) } + ToastEventBus.send(type = Toast.ToastType.ERROR, title = errorResult.error) + } else { + TrezorDebugLog.log("COMPOSE", "=== composeTx FAILED (no valid result) ===") + _uiState.update { it.copy(isComposing = false) } + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "No valid composition returned") + } + } + + fun signComposedTx() { + viewModelScope.launch(bgDispatcher) { + val state = _uiState.value + val result = state.precomposedResult ?: return@launch + + TrezorDebugLog.log("SIGN", "=== signComposedTx START ===") + TrezorDebugLog.log("SIGN", "inputs=${result.inputs.size}, outputs=${result.outputs.size}") + TrezorDebugLog.log("SIGN", "network=${state.selectedNetwork}") + result.inputs.forEach { + TrezorDebugLog.log( + "SIGN", + " input: txid=${it.txid}, vout=${it.vout}, " + + "amount=${it.amount}, scriptType=${it.scriptType}, path=${it.path}" + ) + } + + _uiState.update { it.copy(isSigning = true) } + + TrezorDebugLog.log("SIGN", "Converting precomposed to sign params...") + trezorRepo.convertToSignParams( + inputs = result.inputs, + outputs = result.outputs, + coin = state.selectedNetwork, + ).onSuccess { logAndSign(it) } + .onFailure { + TrezorDebugLog.log("SIGN", "convertToSignParams FAILED: ${it.message}") + _uiState.update { s -> s.copy(isSigning = false) } + ToastEventBus.send(it) + } + } + } + + private suspend fun logAndSign(signParams: TrezorSignTxParams) { + val network = _uiState.value.selectedNetwork + val txids = signParams.inputs.map { it.prevHash }.distinct() + TrezorDebugLog.log( + "SIGN", + "Sign params: inputs=${signParams.inputs.size}, " + + "outputs=${signParams.outputs.size}, coin=${signParams.coin}" + ) + TrezorDebugLog.log("SIGN", "Fetching ${txids.size} prev tx(s) from Electrum...") + trezorRepo.fetchPrevTxs(txids = txids, network = network) + .onSuccess { prevTxs -> + TrezorDebugLog.log("SIGN", "Fetched ${prevTxs.size} prev tx(s)") + val completeParams = signParams.copy(prevTxs = prevTxs) + TrezorDebugLog.log("SIGN", "Calling trezor signTx...") + executeSign(completeParams) + } + .onFailure { + TrezorDebugLog.log("SIGN", "fetchPrevTxs FAILED: ${it.message}") + _uiState.update { s -> s.copy(isSigning = false) } + ToastEventBus.send(it) + } + } + + private suspend fun executeSign(params: TrezorSignTxParams) { + trezorRepo.signTxWithParams(params) + .onSuccess { signedTx -> + TrezorDebugLog.log("SIGN", "=== signComposedTx SUCCESS ===") + TrezorDebugLog.log( + "SIGN", + "signatures=${signedTx.signatures.size}, " + + "txid=${signedTx.txid}, rawTxLen=${signedTx.serializedTx.length}" + ) + _uiState.update { + it.copy(isSigning = false, signedTxResult = signedTx, sendStep = SendStep.SIGNED) + } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = "Transaction signed (${signedTx.signatures.size} inputs)" + ) + } + .onFailure { + TrezorDebugLog.log("SIGN", "signTx FAILED: ${it.message}") + _uiState.update { s -> s.copy(isSigning = false) } + ToastEventBus.send(it) + } + } + fun clearError() { trezorRepo.clearError() } From 03b2019f88bf53c951a2bc088fb6345009567394 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 6 Mar 2026 12:26:00 -0500 Subject: [PATCH 08/48] fix: trezor usb priority and connect guard Co-Authored-By: Claude Opus 4.6 --- .../java/to/bitkit/repositories/TrezorRepo.kt | 75 ++++++++++++------- .../java/to/bitkit/services/TrezorService.kt | 2 +- 2 files changed, 47 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index a3013f89a..f704d101d 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -17,6 +17,7 @@ import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignTxParams import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorTransportType import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import dagger.hilt.android.qualifiers.ApplicationContext @@ -365,9 +366,11 @@ class TrezorRepo @Inject constructor( _state.value.connectedDevice ?: error("Connected but no features") } else { val scannedDevices = scan().getOrThrow() - val match = knownDevices.firstNotNullOfOrNull { known -> + val usbDevice = scannedDevices.find { it.transportType == TrezorTransportType.USB } + val idMatch = knownDevices.firstNotNullOfOrNull { known -> scannedDevices.find { it.id == known.id } - } ?: error("No known device found nearby") + } + val match = usbDevice ?: idMatch ?: error("No known device found nearby") connect(match.id).getOrThrow() } }.onSuccess { @@ -378,35 +381,49 @@ class TrezorRepo @Inject constructor( } } - suspend fun connectKnownDevice(deviceId: String): Result = runCatching { - _state.update { it.copy(isConnecting = true, error = null) } - TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===") - TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId") - TrezorDebugLog.log("RECONNECT", "isInitialized=${_state.value.isInitialized}") - if (!_state.value.isInitialized) { - TrezorDebugLog.log("RECONNECT", "Initializing...") - initialize().getOrThrow() - TrezorDebugLog.log("RECONNECT", "Initialized OK") + suspend fun connectKnownDevice(deviceId: String): Result { + if (_state.value.isConnecting) { + return Result.failure(IllegalStateException("Connection already in progress")) } - TrezorDebugLog.log("RECONNECT", "Scanning for devices...") - val scannedDevices = trezorService.scan() - TrezorDebugLog.log("RECONNECT", "Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}") - val device = scannedDevices.find { it.id == deviceId } - ?: error("Device not found nearby — is it powered on?") - TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}") - TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...") - val features = connectWithThpRetry(device.id) - TrezorDebugLog.log("RECONNECT", "Connected! label=${features.label}, model=${features.model}") - addOrUpdateKnownDevice(device, features) - _state.update { - it.copy(isConnecting = false, connectedDevice = features, connectedDeviceId = deviceId) + return runCatching { + _state.update { it.copy(isConnecting = true, error = null) } + TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===") + TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId") + TrezorDebugLog.log("RECONNECT", "isInitialized=${_state.value.isInitialized}") + if (!_state.value.isInitialized) { + TrezorDebugLog.log("RECONNECT", "Initializing...") + initialize().getOrThrow() + TrezorDebugLog.log("RECONNECT", "Initialized OK") + } + TrezorDebugLog.log("RECONNECT", "Scanning for devices...") + val scannedDevices = trezorService.scan() + TrezorDebugLog.log( + "RECONNECT", + "Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}", + ) + val exactMatch = scannedDevices.find { it.id == deviceId } + val usbDevice = scannedDevices.find { it.transportType == TrezorTransportType.USB } + val device = if (exactMatch?.transportType == TrezorTransportType.BLUETOOTH && usbDevice != null) { + TrezorDebugLog.log("RECONNECT", "Preferring USB over BLE") + usbDevice + } else { + exactMatch ?: error("Device not found nearby — is it powered on?") + } + TrezorDebugLog.log("RECONNECT", "Found matching device: id=${device.id}, name=${device.name}") + TrezorDebugLog.log("RECONNECT", "Calling connectWithThpRetry...") + val features = connectWithThpRetry(device.id) + TrezorDebugLog.log("RECONNECT", "Connected! label=${features.label}, model=${features.model}") + addOrUpdateKnownDevice(device, features) + _state.update { + it.copy(isConnecting = false, connectedDevice = features, connectedDeviceId = device.id) + } + TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice SUCCESS ===") + features + }.onFailure { e -> + TrezorDebugLog.log("RECONNECT", "FAILED: ${e.message}") + Logger.error("Connect known device failed", e, context = TAG) + _state.update { it.copy(isConnecting = false, error = e.message) } } - TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice SUCCESS ===") - features - }.onFailure { e -> - TrezorDebugLog.log("RECONNECT", "FAILED: ${e.message}") - Logger.error("Connect known device failed", e, context = TAG) - _state.update { it.copy(isConnecting = false, error = e.message) } } suspend fun forgetDevice(deviceId: String): Result = runCatching { diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 07e520c57..929ffbe87 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -22,10 +22,10 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import com.synonym.bitkitcore.TrezorVerifyMessageParams +import com.synonym.bitkitcore.trezorBroadcastRawTx import com.synonym.bitkitcore.trezorClearCredentials import com.synonym.bitkitcore.trezorConnect import com.synonym.bitkitcore.trezorDisconnect -import com.synonym.bitkitcore.trezorBroadcastRawTx import com.synonym.bitkitcore.trezorFetchPrevTxs import com.synonym.bitkitcore.trezorGetAccountInfo import com.synonym.bitkitcore.trezorGetAddress From dccbab3970bfd2b5cf2324f108c0399f48b83d40 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Sat, 7 Mar 2026 13:28:24 -0500 Subject: [PATCH 09/48] fix: bump bitkit-core version - Bumps bitkit-core version to 0.1.44 - Adds SendTransactionSection.kt --- .../screens/trezor/SendTransactionSection.kt | 456 ++++++++++++++++++ gradle/libs.versions.toml | 2 +- 2 files changed, 457 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt new file mode 100644 index 000000000..5967d09e4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -0,0 +1,456 @@ +package to.bitkit.ui.screens.trezor + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.synonym.bitkitcore.TrezorPrecomposedOutput +import com.synonym.bitkitcore.TrezorPrecomposedResult +import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorSortingStrategy +import to.bitkit.R +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.utils.copyToClipboard +import to.bitkit.viewmodels.SendStep +import to.bitkit.viewmodels.TrezorUiState + +private val textFieldColors + @Composable get() = OutlinedTextFieldDefaults.colors( + focusedTextColor = Colors.White, + unfocusedTextColor = Colors.White, + focusedBorderColor = Colors.Brand, + unfocusedBorderColor = Colors.White32, + cursorColor = Colors.Brand, + disabledTextColor = Colors.White50, + disabledBorderColor = Colors.White16, + ) + +@Composable +internal fun SendTransactionSection( + uiState: TrezorUiState, + isDeviceConnected: Boolean, + onAddressChange: (String) -> Unit, + onAmountChange: (String) -> Unit, + onFeeRateChange: (String) -> Unit, + onToggleSendMax: () -> Unit, + onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCompose: () -> Unit, + onSign: () -> Unit, + onBroadcast: () -> Unit, + onBack: () -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + text = "Send Transaction", + color = Colors.White64, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + + when (uiState.sendStep) { + SendStep.FORM -> ComposeForm( + uiState = uiState, + onAddressChange = onAddressChange, + onAmountChange = onAmountChange, + onFeeRateChange = onFeeRateChange, + onToggleSendMax = onToggleSendMax, + onSortingStrategyChange = onSortingStrategyChange, + onCompose = onCompose, + ) + SendStep.REVIEW -> ReviewSection( + result = uiState.precomposedResult!!, + isDeviceConnected = isDeviceConnected, + isSigning = uiState.isSigning, + onSign = onSign, + onBack = onBack, + ) + SendStep.SIGNED -> SignedResultSection( + signedTx = uiState.signedTxResult!!, + isBroadcasting = uiState.isBroadcasting, + broadcastTxid = uiState.broadcastTxid, + onBroadcast = onBroadcast, + onReset = onReset, + ) + } + } +} + +@Composable +private fun ComposeForm( + uiState: TrezorUiState, + onAddressChange: (String) -> Unit, + onAmountChange: (String) -> Unit, + onFeeRateChange: (String) -> Unit, + onToggleSendMax: () -> Unit, + onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCompose: () -> Unit, +) { + Column { + OutlinedTextField( + value = uiState.sendAddress, + onValueChange = onAddressChange, + label = { Text("Destination address", color = Colors.White50) }, + colors = textFieldColors, + maxLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = if (uiState.isSendMax) "MAX" else uiState.sendAmountSats, + onValueChange = onAmountChange, + label = { Text("Amount (sats)", color = Colors.White50) }, + colors = textFieldColors, + enabled = !uiState.isSendMax, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + ) + val maxColor = if (uiState.isSendMax) Colors.Brand else Colors.White32 + Text( + text = "MAX", + color = maxColor, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(maxColor.copy(alpha = 0.15f)) + .clickableAlpha(onClick = onToggleSendMax) + .padding(horizontal = 12.dp, vertical = 12.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = uiState.sendFeeRate, + onValueChange = onFeeRateChange, + label = { Text("Fee rate (sat/vB)", color = Colors.White50) }, + colors = textFieldColors, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Coin Selection", + color = Colors.White64, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(4.dp)) + SortingStrategyRow( + selected = uiState.sortingStrategy, + onChange = onSortingStrategyChange, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + PrimaryButton( + text = if (uiState.isComposing) "Composing..." else "Compose Transaction", + onClick = onCompose, + enabled = !uiState.isComposing && + uiState.sendAddress.isNotBlank() && + (uiState.isSendMax || uiState.sendAmountSats.isNotBlank()), + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun SortingStrategyRow( + selected: TrezorSortingStrategy, + onChange: (TrezorSortingStrategy) -> Unit, +) { + val labels = mapOf( + TrezorSortingStrategy.BIP69 to "BIP69", + TrezorSortingStrategy.RANDOM to "Random", + TrezorSortingStrategy.NONE to "None", + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TrezorSortingStrategy.entries.forEach { strategy -> + val isSelected = strategy == selected + val color = if (isSelected) Colors.Brand else Colors.White32 + Text( + text = labels[strategy] ?: strategy.name, + color = color, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(color.copy(alpha = 0.15f)) + .clickableAlpha(onClick = { onChange(strategy) }) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } +} + +@Composable +private fun ReviewSection( + result: TrezorPrecomposedResult.Final, + isDeviceConnected: Boolean, + isSigning: Boolean, + onSign: () -> Unit, + onBack: () -> Unit, +) { + Column { + ResultCard { + InfoRow("Total Spent", "${result.totalSpent} sats") + InfoRow("Fee", "${result.fee} sats") + InfoRow("Fee Rate", "${result.feePerByte} sat/vB") + InfoRow("Size", "${result.bytes} bytes") + InfoRow("Inputs", "${result.inputs.size}") + InfoRow("Outputs", "${result.outputs.size}") + } + + if (result.inputs.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Inputs (${result.inputs.size})", + color = Colors.White64, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(4.dp)) + result.inputs.forEach { input -> + ResultCard { + Text( + text = "${input.txid.take(8)}...${input.txid.takeLast(8)}:${input.vout}", + color = Colors.Brand, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ) + InfoRow("Amount", "${input.amount} sats") + InfoRow("Path", input.path) + } + Spacer(modifier = Modifier.height(4.dp)) + } + } + + if (result.outputs.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Outputs (${result.outputs.size})", + color = Colors.White64, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(4.dp)) + result.outputs.forEach { output -> + OutputCard(output) + Spacer(modifier = Modifier.height(4.dp)) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + SecondaryButton( + text = "Back", + onClick = onBack, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + PrimaryButton( + text = if (isSigning) "Signing..." else "Sign with Trezor", + onClick = onSign, + enabled = !isSigning && isDeviceConnected, + size = ButtonSize.Small, + modifier = Modifier.weight(1f), + ) + } + + if (!isDeviceConnected) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Connect a Trezor device to sign", + color = Colors.White32, + fontSize = 10.sp, + ) + } + } +} + +@Composable +private fun OutputCard(output: TrezorPrecomposedOutput) { + ResultCard { + when (output) { + is TrezorPrecomposedOutput.Payment -> { + InfoRow("Type", "Payment") + Text( + text = output.address, + color = Colors.Brand, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ) + InfoRow("Amount", "${output.amount} sats") + } + is TrezorPrecomposedOutput.Change -> { + InfoRow("Type", "Change") + Text( + text = output.address, + color = Colors.White64, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + ) + InfoRow("Amount", "${output.amount} sats") + InfoRow("Path", output.path) + } + is TrezorPrecomposedOutput.OpReturn -> { + InfoRow("Type", "OP_RETURN") + InfoRow("Data", output.dataHex) + } + } + } +} + +@Composable +private fun SignedResultSection( + signedTx: TrezorSignedTx, + isBroadcasting: Boolean, + broadcastTxid: String?, + onBroadcast: () -> Unit, + onReset: () -> Unit, +) { + val onCopyRawTx = copyToClipboard(text = signedTx.serializedTx, label = "Raw Transaction") + + Column { + ResultCard { + InfoRow("Signatures", "${signedTx.signatures.size}") + signedTx.txid?.let { InfoRow("TXID", it) } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Raw Transaction Hex", + color = Colors.White64, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(4.dp)) + + ResultCard { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = signedTx.serializedTx, + color = Colors.White, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 14.sp, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.ic_copy), + contentDescription = "Copy raw tx", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyRawTx), + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (broadcastTxid != null) { + BroadcastResultCard(txid = broadcastTxid) + Spacer(modifier = Modifier.height(12.dp)) + } else { + PrimaryButton( + text = if (isBroadcasting) "Broadcasting..." else "Broadcast", + onClick = onBroadcast, + enabled = !isBroadcasting, + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + SecondaryButton( + text = "New Transaction", + onClick = onReset, + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun BroadcastResultCard(txid: String) { + val onCopyTxid = copyToClipboard(text = txid, label = "TXID") + ResultCard { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Broadcast TXID", + color = Colors.White64, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = androidx.compose.ui.res.painterResource(R.drawable.ic_copy), + contentDescription = "Copy txid", + tint = Colors.Brand, + modifier = Modifier + .size(16.dp) + .clickableAlpha(onClick = onCopyTxid), + ) + } + Text( + text = txid, + color = Colors.Brand, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 14.sp, + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c7a6ad529..c2e28ab55 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.42" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.44" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From c6b7973e1f0fc378e99723f6c540514c951ba39a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Mar 2026 10:04:00 -0300 Subject: [PATCH 10/48] chore: lint --- .../main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index ad72022d5..93dced973 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -544,6 +544,7 @@ private fun StatusRow(trezorState: TrezorState) { Spacer(modifier = Modifier.width(8.dp)) Text("Reconnecting...", color = Colors.White64, fontSize = 12.sp) } + trezorState.isScanning -> { CircularProgressIndicator( modifier = Modifier.size(16.dp), @@ -553,6 +554,7 @@ private fun StatusRow(trezorState: TrezorState) { Spacer(modifier = Modifier.width(8.dp)) Text("Scanning...", color = Colors.White64, fontSize = 12.sp) } + trezorState.isConnecting -> { CircularProgressIndicator( modifier = Modifier.size(16.dp), @@ -562,12 +564,15 @@ private fun StatusRow(trezorState: TrezorState) { Spacer(modifier = Modifier.width(8.dp)) Text("Connecting...", color = Colors.White64, fontSize = 12.sp) } + trezorState.connectedDevice != null -> { StatusBadge(text = "Connected", color = Colors.Green) } + trezorState.isInitialized -> { StatusBadge(text = "Ready", color = Colors.Brand) } + else -> { StatusBadge(text = "Not initialized", color = Colors.White32) } From 463750843ad62fe87d961df909faf0c174f6f52d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Mar 2026 10:34:38 -0300 Subject: [PATCH 11/48] refactor: replace Spacer composable with VerticalSpacer/HorizontalSpacer --- .../ui/screens/trezor/AddressSection.kt | 17 ++++--- .../ui/screens/trezor/BalanceLookupSection.kt | 31 ++++++------ .../ui/screens/trezor/DeviceListSection.kt | 7 ++- .../ui/screens/trezor/PairingCodeDialog.kt | 5 +- .../ui/screens/trezor/PublicKeySection.kt | 21 ++++----- .../screens/trezor/SendTransactionSection.kt | 47 +++++++++---------- .../ui/screens/trezor/SignMessageSection.kt | 15 +++--- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 46 +++++++++--------- 8 files changed, 90 insertions(+), 99 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index eb3b487de..d72283c5d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -5,12 +5,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -26,8 +23,10 @@ import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -47,7 +46,7 @@ internal fun AddressSection( fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Text( text = "Path: ${uiState.derivationPath}", @@ -56,7 +55,7 @@ internal fun AddressSection( fontFamily = FontFamily.Monospace, ) - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -82,13 +81,13 @@ internal fun AddressSection( trezorState.lastAddress?.let { response -> val onCopyAddress = copyToClipboard(text = response.address, label = "Address") Column { - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Text( text = "Address:", color = Colors.White50, fontSize = 11.sp, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) Row( modifier = Modifier .fillMaxWidth() @@ -104,7 +103,7 @@ internal fun AddressSection( fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy address", @@ -114,7 +113,7 @@ internal fun AddressSection( .clickableAlpha(onClick = onCopyAddress) ) } - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) SecondaryButton( text = "Next Index", onClick = onIncrementIndex, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index e5d791c31..84f0d57ce 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -4,12 +4,9 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField @@ -30,7 +27,9 @@ import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -61,7 +60,7 @@ internal fun BalanceLookupSection( fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) OutlinedTextField( value = uiState.lookupInput, @@ -78,7 +77,7 @@ internal fun BalanceLookupSection( modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) PrimaryButton( text = if (uiState.isLookingUp) "Looking up..." else "Lookup", @@ -132,7 +131,7 @@ private fun AccountInfoResultView( onResetSend: () -> Unit, ) { Column { - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) ResultCard { InfoRow("Account Type", result.accountType.name) InfoRow("Balance", "${result.balance} sats") @@ -141,22 +140,22 @@ private fun AccountInfoResultView( } if (result.account.utxo.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Text( text = "UTXOs (${result.account.utxo.size})", color = Colors.White64, fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) result.account.utxo.forEach { utxo -> UtxoRow(utxo) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) } } if (result.balance > 0uL) { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) SendTransactionSection( uiState = uiState, isDeviceConnected = isDeviceConnected, @@ -179,7 +178,7 @@ private fun AccountInfoResultView( private fun AddressInfoResultView(result: SingleAddressInfoResult) { val onCopyAddress = copyToClipboard(text = result.address, label = "Address") Column { - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) ResultCard { Row( verticalAlignment = Alignment.CenterVertically, @@ -192,7 +191,7 @@ private fun AddressInfoResultView(result: SingleAddressInfoResult) { fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy address", @@ -209,17 +208,17 @@ private fun AddressInfoResultView(result: SingleAddressInfoResult) { } if (result.utxos.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Text( text = "UTXOs (${result.utxos.size})", color = Colors.White64, fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) result.utxos.forEach { utxo -> UtxoRow(utxo) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) } } } @@ -240,7 +239,7 @@ private fun UtxoRow(utxo: AccountUtxo) { fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy txid", diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index a1f968e4c..1be0675a9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -5,11 +5,9 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -26,6 +24,7 @@ import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R import to.bitkit.repositories.KnownDevice +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors @@ -55,7 +54,7 @@ internal fun DeviceCard( tint = Colors.White64, modifier = Modifier.size(24.dp) ) - Spacer(modifier = Modifier.width(12.dp)) + HorizontalSpacer(12.dp) Column(modifier = Modifier.weight(1f)) { Text( text = device.label ?: device.name ?: device.model ?: "Trezor", @@ -109,7 +108,7 @@ internal fun KnownDeviceCard( tint = if (isConnected) Colors.Green else Colors.White64, modifier = Modifier.size(24.dp) ) - Spacer(modifier = Modifier.width(12.dp)) + HorizontalSpacer(12.dp) Column(modifier = Modifier.weight(1f)) { Text( text = device.label ?: device.name ?: device.model ?: "Trezor", diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt index f79da3571..06b1cc64b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -1,9 +1,7 @@ package to.bitkit.ui.screens.trezor import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField @@ -21,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.theme.Colors @Composable @@ -47,7 +46,7 @@ internal fun PairingCodeDialog( color = Colors.White80, fontSize = 14.sp, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) OutlinedTextField( value = code, onValueChange = { newValue -> diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index 1cac1fd1e..7e54a7c2f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -5,12 +5,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -27,8 +24,10 @@ import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -51,7 +50,7 @@ internal fun PublicKeySection( fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Text( text = "Account path: $accountPath", @@ -60,7 +59,7 @@ internal fun PublicKeySection( fontFamily = FontFamily.Monospace, ) - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -87,13 +86,13 @@ internal fun PublicKeySection( val onCopyXpub = copyToClipboard(text = response.xpub, label = "xpub") val onCopyPublicKey = copyToClipboard(text = response.publicKey, label = "Public Key") Column { - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Text( text = "xpub:", color = Colors.White50, fontSize = 11.sp, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) Row( modifier = Modifier .fillMaxWidth() @@ -109,7 +108,7 @@ internal fun PublicKeySection( fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy xpub", @@ -120,13 +119,13 @@ internal fun PublicKeySection( ) } - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Text( text = "Public Key:", color = Colors.White50, fontSize = 11.sp, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) Row( modifier = Modifier .fillMaxWidth() @@ -142,7 +141,7 @@ internal fun PublicKeySection( fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy public key", diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 5967d09e4..72ed55526 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -4,12 +4,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon @@ -31,8 +28,10 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -73,7 +72,7 @@ internal fun SendTransactionSection( fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) when (uiState.sendStep) { SendStep.FORM -> ComposeForm( @@ -123,7 +122,7 @@ private fun ComposeForm( modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -154,7 +153,7 @@ private fun ComposeForm( ) } - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) OutlinedTextField( value = uiState.sendFeeRate, @@ -166,7 +165,7 @@ private fun ComposeForm( modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Text( text = "Coin Selection", @@ -174,13 +173,13 @@ private fun ComposeForm( fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) SortingStrategyRow( selected = uiState.sortingStrategy, onChange = onSortingStrategyChange, ) - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) PrimaryButton( text = if (uiState.isComposing) "Composing..." else "Compose Transaction", @@ -242,14 +241,14 @@ private fun ReviewSection( } if (result.inputs.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Text( text = "Inputs (${result.inputs.size})", color = Colors.White64, fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) result.inputs.forEach { input -> ResultCard { Text( @@ -261,26 +260,26 @@ private fun ReviewSection( InfoRow("Amount", "${input.amount} sats") InfoRow("Path", input.path) } - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) } } if (result.outputs.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Text( text = "Outputs (${result.outputs.size})", color = Colors.White64, fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) result.outputs.forEach { output -> OutputCard(output) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) } } - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -302,7 +301,7 @@ private fun ReviewSection( } if (!isDeviceConnected) { - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) Text( text = "Connect a Trezor device to sign", color = Colors.White32, @@ -361,7 +360,7 @@ private fun SignedResultSection( signedTx.txid?.let { InfoRow("TXID", it) } } - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) Text( text = "Raw Transaction Hex", @@ -369,7 +368,7 @@ private fun SignedResultSection( fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) ResultCard { Row( @@ -384,7 +383,7 @@ private fun SignedResultSection( lineHeight = 14.sp, modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = androidx.compose.ui.res.painterResource(R.drawable.ic_copy), contentDescription = "Copy raw tx", @@ -396,11 +395,11 @@ private fun SignedResultSection( } } - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) if (broadcastTxid != null) { BroadcastResultCard(txid = broadcastTxid) - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) } else { PrimaryButton( text = if (isBroadcasting) "Broadcasting..." else "Broadcast", @@ -409,7 +408,7 @@ private fun SignedResultSection( size = ButtonSize.Small, modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) } SecondaryButton( @@ -435,7 +434,7 @@ private fun BroadcastResultCard(txid: String) { fontSize = 11.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = androidx.compose.ui.res.painterResource(R.drawable.ic_copy), contentDescription = "Copy txid", diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index fda405957..a65bdc0aa 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -5,12 +5,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField @@ -28,8 +25,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -49,7 +48,7 @@ internal fun SignMessageSection( fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) OutlinedTextField( value = uiState.messageToSign, @@ -66,7 +65,7 @@ internal fun SignMessageSection( singleLine = true, ) - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -94,13 +93,13 @@ internal fun SignMessageSection( uiState.lastSignature?.let { sig -> val onCopySignature = copyToClipboard(text = sig, label = "Signature") Column { - Spacer(modifier = Modifier.height(12.dp)) + VerticalSpacer(12.dp) Text( text = "Signature:", color = Colors.White50, fontSize = 11.sp, ) - Spacer(modifier = Modifier.height(4.dp)) + VerticalSpacer(4.dp) Row( modifier = Modifier .fillMaxWidth() @@ -118,7 +117,7 @@ internal fun SignMessageSection( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy signature", diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 93dced973..6043b3b30 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -11,14 +11,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -59,6 +56,7 @@ import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import to.bitkit.services.TrezorDebugLog import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Text13Up @@ -220,7 +218,7 @@ private fun TrezorContent( ) { StatusRow(trezorState) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) ActionButtonsRow( trezorState = trezorState, @@ -237,14 +235,14 @@ private fun TrezorContent( exit = fadeOut() + shrinkVertically(), ) { Column { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Text( text = "My Devices (${trezorState.knownDevices.size})", color = Colors.White64, fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) trezorState.knownDevices.forEach { device -> val isConnected = trezorState.connectedDeviceId == device.id KnownDeviceCard( @@ -257,7 +255,7 @@ private fun TrezorContent( }, onForget = { onForgetDevice(device) }, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) } } } @@ -269,14 +267,14 @@ private fun TrezorContent( exit = fadeOut() + shrinkVertically(), ) { Column { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Text( text = "New Devices (${trezorState.nearbyDevices.size})", color = Colors.White64, fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) trezorState.nearbyDevices.forEach { device -> DeviceCard( device = device, @@ -286,7 +284,7 @@ private fun TrezorContent( } }, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) } } } @@ -299,18 +297,18 @@ private fun TrezorContent( ) { trezorState.connectedDevice?.let { features -> Column { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Text( text = "Connected Device", color = Colors.White64, fontSize = 12.sp, fontWeight = FontWeight.Medium, ) - Spacer(modifier = Modifier.height(8.dp)) + VerticalSpacer(8.dp) ConnectedDeviceInfo(features) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) AddressSection( trezorState = trezorState, @@ -319,7 +317,7 @@ private fun TrezorContent( onIncrementIndex = onIncrementIndex, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) PublicKeySection( trezorState = trezorState, @@ -327,7 +325,7 @@ private fun TrezorContent( onGetPublicKey = onGetPublicKey, ) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) SignMessageSection( uiState = uiState, @@ -344,7 +342,7 @@ private fun TrezorContent( trezorState.error?.let { error -> val onCopyError = copyToClipboard(text = error, label = "Trezor Error") Column { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Row( modifier = Modifier .fillMaxWidth() @@ -360,7 +358,7 @@ private fun TrezorContent( fontSize = 12.sp, modifier = Modifier.weight(1f) ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy error", @@ -369,7 +367,7 @@ private fun TrezorContent( .size(20.dp) .clickableAlpha(onClick = onCopyError) ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Icon( painter = painterResource(R.drawable.ic_x), contentDescription = "Dismiss error", @@ -384,7 +382,7 @@ private fun TrezorContent( } // Balance Lookup (always visible, no device needed) - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) BalanceLookupSection( uiState = uiState, isDeviceConnected = trezorState.connectedDevice != null, @@ -442,7 +440,7 @@ private fun DebugLogSection() { val debugLines by TrezorDebugLog.lines.collectAsStateWithLifecycle() Column { - Spacer(modifier = Modifier.height(16.dp)) + VerticalSpacer(16.dp) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -524,7 +522,7 @@ private fun StatusRow(trezorState: TrezorState) { tint = Colors.White80, modifier = Modifier.size(20.dp) ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Text( text = "Trezor", color = Colors.White, @@ -541,7 +539,7 @@ private fun StatusRow(trezorState: TrezorState) { strokeWidth = 2.dp, color = Colors.Brand ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Text("Reconnecting...", color = Colors.White64, fontSize = 12.sp) } @@ -551,7 +549,7 @@ private fun StatusRow(trezorState: TrezorState) { strokeWidth = 2.dp, color = Colors.Brand ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Text("Scanning...", color = Colors.White64, fontSize = 12.sp) } @@ -561,7 +559,7 @@ private fun StatusRow(trezorState: TrezorState) { strokeWidth = 2.dp, color = Colors.Brand ) - Spacer(modifier = Modifier.width(8.dp)) + HorizontalSpacer(8.dp) Text("Connecting...", color = Colors.White64, fontSize = 12.sp) } From a5b0f64f06d8423c7ed020eda7df0b8f9a39900a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Mar 2026 10:57:53 -0300 Subject: [PATCH 12/48] refactor: replace raw text with custom components --- .../ui/screens/trezor/AddressSection.kt | 6 ++-- .../ui/screens/trezor/BalanceLookupSection.kt | 5 ++- .../screens/trezor/ConnectedDeviceSection.kt | 11 ++---- .../ui/screens/trezor/DeviceListSection.kt | 10 +++--- .../ui/screens/trezor/PublicKeySection.kt | 6 ++-- .../screens/trezor/SendTransactionSection.kt | 13 +++---- .../ui/screens/trezor/SignMessageSection.kt | 6 ++-- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 34 +++++++------------ 8 files changed, 31 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index d72283c5d..f61b50104 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -17,12 +17,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -40,11 +40,9 @@ internal fun AddressSection( onIncrementIndex: () -> Unit, ) { Column { - Text( + Footnote( text = "Address Generation", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index 84f0d57ce..8fd92976c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -27,6 +27,7 @@ import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.VerticalSpacer @@ -54,11 +55,9 @@ internal fun BalanceLookupSection( onResetSend: () -> Unit, ) { Column { - Text( + Footnote( text = "Balance Lookup", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt index 8a0dd0e62..dd1e8c74a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt @@ -7,14 +7,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.synonym.bitkitcore.TrezorFeatures +import to.bitkit.ui.components.Footnote import to.bitkit.ui.theme.Colors @Composable @@ -45,16 +43,13 @@ internal fun InfoRow(label: String, value: String) { .padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween ) { - Text( + Footnote( text = label, color = Colors.White50, - fontSize = 12.sp, ) - Text( + Footnote( text = value, color = Colors.White, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index 1be0675a9..18be229ca 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -24,6 +24,7 @@ import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R import to.bitkit.repositories.KnownDevice +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors @@ -64,13 +65,12 @@ internal fun DeviceCard( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( + Footnote( text = when (device.transportType) { TrezorTransportType.USB -> "USB" TrezorTransportType.BLUETOOTH -> "Bluetooth" }, color = Colors.White50, - fontSize = 12.sp, ) } } @@ -122,15 +122,13 @@ internal fun KnownDeviceCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( + Footnote( text = if (device.transportType == "bluetooth") "Bluetooth" else "USB", color = Colors.White50, - fontSize = 12.sp, ) - Text( + Footnote( text = if (isConnected) "Connected" else "Disconnected", color = if (isConnected) Colors.Green else Colors.White32, - fontSize = 12.sp, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index 7e54a7c2f..bc41610ed 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -18,12 +18,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -44,11 +44,9 @@ internal fun PublicKeySection( } Column { - Text( + Footnote( text = "Public Key (xpub)", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 72ed55526..dca04b811 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -28,6 +28,7 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -66,11 +67,9 @@ internal fun SendTransactionSection( modifier: Modifier = Modifier, ) { Column(modifier = modifier) { - Text( + Footnote( text = "Send Transaction", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) @@ -140,11 +139,9 @@ private fun ComposeForm( modifier = Modifier.weight(1f), ) val maxColor = if (uiState.isSendMax) Colors.Brand else Colors.White32 - Text( + Footnote( text = "MAX", color = maxColor, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .background(maxColor.copy(alpha = 0.15f)) @@ -207,11 +204,9 @@ private fun SortingStrategyRow( TrezorSortingStrategy.entries.forEach { strategy -> val isSelected = strategy == selected val color = if (isSelected) Colors.Brand else Colors.White32 - Text( + Footnote( text = labels[strategy] ?: strategy.name, color = color, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .background(color.copy(alpha = 0.15f)) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index a65bdc0aa..77d76b219 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -19,12 +19,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -42,11 +42,9 @@ internal fun SignMessageSection( onVerifyMessage: () -> Unit, ) { Column { - Text( + Footnote( text = "Sign Message", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 6043b3b30..df73187c9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -56,6 +56,7 @@ import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import to.bitkit.services.TrezorDebugLog import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -236,11 +237,9 @@ private fun TrezorContent( ) { Column { VerticalSpacer(16.dp) - Text( + Footnote( text = "My Devices (${trezorState.knownDevices.size})", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) trezorState.knownDevices.forEach { device -> @@ -268,11 +267,9 @@ private fun TrezorContent( ) { Column { VerticalSpacer(16.dp) - Text( + Footnote( text = "New Devices (${trezorState.nearbyDevices.size})", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) trezorState.nearbyDevices.forEach { device -> @@ -298,11 +295,9 @@ private fun TrezorContent( trezorState.connectedDevice?.let { features -> Column { VerticalSpacer(16.dp) - Text( + Footnote( text = "Connected Device", color = Colors.White64, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(8.dp) @@ -352,11 +347,10 @@ private fun TrezorContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top ) { - Text( + Footnote( text = error, color = Colors.Red, - fontSize = 12.sp, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) Icon( @@ -419,11 +413,9 @@ private fun NetworkSelectorRow( TrezorCoinType.entries.filter { it != TrezorCoinType.SIGNET }.forEach { network -> val isSelected = network == selectedNetwork val color = if (isSelected) Colors.Brand else Colors.White32 - Text( + Footnote( text = network.name, color = color, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .background(color.copy(alpha = 0.15f)) @@ -540,7 +532,7 @@ private fun StatusRow(trezorState: TrezorState) { color = Colors.Brand ) HorizontalSpacer(8.dp) - Text("Reconnecting...", color = Colors.White64, fontSize = 12.sp) + Footnote("Reconnecting...", color = Colors.White64) } trezorState.isScanning -> { @@ -550,7 +542,7 @@ private fun StatusRow(trezorState: TrezorState) { color = Colors.Brand ) HorizontalSpacer(8.dp) - Text("Scanning...", color = Colors.White64, fontSize = 12.sp) + Footnote("Scanning...", color = Colors.White64) } trezorState.isConnecting -> { @@ -560,7 +552,7 @@ private fun StatusRow(trezorState: TrezorState) { color = Colors.Brand ) HorizontalSpacer(8.dp) - Text("Connecting...", color = Colors.White64, fontSize = 12.sp) + Footnote("Connecting...", color = Colors.White64) } trezorState.connectedDevice != null -> { @@ -581,15 +573,13 @@ private fun StatusRow(trezorState: TrezorState) { @Composable private fun StatusBadge(text: String, color: androidx.compose.ui.graphics.Color) { - Text( + Footnote( text = text, color = color, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, modifier = Modifier .clip(RoundedCornerShape(4.dp)) .background(color.copy(alpha = 0.15f)) - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 8.dp, vertical = 4.dp), ) } From 6fc047c9e3a752e526e10df5613e5cf7118afa91 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Mar 2026 11:12:48 -0300 Subject: [PATCH 13/48] refactor: replace mising components fix the closet references --- .../ui/screens/trezor/AddressSection.kt | 3 +-- .../ui/screens/trezor/BalanceLookupSection.kt | 9 ++----- .../ui/screens/trezor/DeviceListSection.kt | 12 +++------ .../ui/screens/trezor/PublicKeySection.kt | 6 ++--- .../screens/trezor/SendTransactionSection.kt | 27 +++++-------------- .../ui/screens/trezor/SignMessageSection.kt | 3 +-- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 6 ++--- 7 files changed, 17 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index f61b50104..1e42b39c6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -80,10 +80,9 @@ internal fun AddressSection( val onCopyAddress = copyToClipboard(text = response.address, label = "Address") Column { VerticalSpacer(12.dp) - Text( + Footnote( text = "Address:", color = Colors.White50, - fontSize = 11.sp, ) VerticalSpacer(4.dp) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index 8fd92976c..9926250d0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.synonym.bitkitcore.AccountInfoResult @@ -140,11 +139,9 @@ private fun AccountInfoResultView( if (result.account.utxo.isNotEmpty()) { VerticalSpacer(8.dp) - Text( + Footnote( text = "UTXOs (${result.account.utxo.size})", color = Colors.White64, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(4.dp) result.account.utxo.forEach { utxo -> @@ -208,11 +205,9 @@ private fun AddressInfoResultView(result: SingleAddressInfoResult) { if (result.utxos.isNotEmpty()) { VerticalSpacer(8.dp) - Text( + Footnote( text = "UTXOs (${result.utxos.size})", color = Colors.White64, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(4.dp) result.utxos.forEach { utxo -> diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index 18be229ca..c91004e5b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -10,20 +10,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R import to.bitkit.repositories.KnownDevice +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha @@ -57,11 +55,9 @@ internal fun DeviceCard( ) HorizontalSpacer(12.dp) Column(modifier = Modifier.weight(1f)) { - Text( + CaptionB( text = device.label ?: device.name ?: device.model ?: "Trezor", color = Colors.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -110,11 +106,9 @@ internal fun KnownDeviceCard( ) HorizontalSpacer(12.dp) Column(modifier = Modifier.weight(1f)) { - Text( + CaptionB( text = device.label ?: device.name ?: device.model ?: "Trezor", color = Colors.White, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index bc41610ed..a5aedea25 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -85,10 +85,9 @@ internal fun PublicKeySection( val onCopyPublicKey = copyToClipboard(text = response.publicKey, label = "Public Key") Column { VerticalSpacer(12.dp) - Text( + Footnote( text = "xpub:", color = Colors.White50, - fontSize = 11.sp, ) VerticalSpacer(4.dp) Row( @@ -118,10 +117,9 @@ internal fun PublicKeySection( } VerticalSpacer(12.dp) - Text( + Footnote( text = "Public Key:", color = Colors.White50, - fontSize = 11.sp, ) VerticalSpacer(4.dp) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index dca04b811..27e686522 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -164,11 +163,9 @@ private fun ComposeForm( VerticalSpacer(12.dp) - Text( + Footnote( text = "Coin Selection", color = Colors.White64, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(4.dp) SortingStrategyRow( @@ -237,11 +234,9 @@ private fun ReviewSection( if (result.inputs.isNotEmpty()) { VerticalSpacer(8.dp) - Text( + Footnote( text = "Inputs (${result.inputs.size})", color = Colors.White64, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(4.dp) result.inputs.forEach { input -> @@ -261,11 +256,9 @@ private fun ReviewSection( if (result.outputs.isNotEmpty()) { VerticalSpacer(8.dp) - Text( + Footnote( text = "Outputs (${result.outputs.size})", color = Colors.White64, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(4.dp) result.outputs.forEach { output -> @@ -297,11 +290,7 @@ private fun ReviewSection( if (!isDeviceConnected) { VerticalSpacer(4.dp) - Text( - text = "Connect a Trezor device to sign", - color = Colors.White32, - fontSize = 10.sp, - ) + Footnote(text = "Connect a Trezor device to sign") } } } @@ -357,11 +346,9 @@ private fun SignedResultSection( VerticalSpacer(8.dp) - Text( + Footnote( text = "Raw Transaction Hex", color = Colors.White64, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, ) VerticalSpacer(4.dp) @@ -423,11 +410,9 @@ private fun BroadcastResultCard(txid: String) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Text( + Footnote( text = "Broadcast TXID", color = Colors.White64, - fontSize = 11.sp, - fontWeight = FontWeight.Medium, ) HorizontalSpacer(8.dp) Icon( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index 77d76b219..afb4a7d14 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -92,10 +92,9 @@ internal fun SignMessageSection( val onCopySignature = copyToClipboard(text = sig, label = "Signature") Column { VerticalSpacer(12.dp) - Text( + Footnote( text = "Signature:", color = Colors.White50, - fontSize = 11.sp, ) VerticalSpacer(4.dp) Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index df73187c9..c033e2b38 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -55,6 +54,7 @@ import to.bitkit.R import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import to.bitkit.services.TrezorDebugLog +import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer @@ -515,11 +515,9 @@ private fun StatusRow(trezorState: TrezorState) { modifier = Modifier.size(20.dp) ) HorizontalSpacer(8.dp) - Text( + BodySSB( text = "Trezor", color = Colors.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, ) } From 54a14d17a5df0f0ff96f0a7cefa2ad9a472389b1 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 9 Mar 2026 10:30:56 -0400 Subject: [PATCH 14/48] fix: address pr #792 review feedback --- app/src/main/java/to/bitkit/ext/Context.kt | 8 ++ .../java/to/bitkit/repositories/TrezorRepo.kt | 49 +++++++------ .../java/to/bitkit/services/TrezorDebugLog.kt | 14 ++-- .../to/bitkit/services/TrezorTransport.kt | 46 ++++++------ .../ui/screens/trezor/AddressSection.kt | 1 - .../ui/screens/trezor/BalanceLookupSection.kt | 1 - .../ui/screens/trezor/PairingCodeDialog.kt | 24 +++--- .../ui/screens/trezor/PublicKeySection.kt | 1 - .../screens/trezor/SendTransactionSection.kt | 39 +++++----- .../ui/screens/trezor/SignMessageSection.kt | 1 - .../bitkit/ui/screens/trezor/TrezorScreen.kt | 8 +- .../screens/trezor}/TrezorViewModel.kt | 73 ++++++++++--------- .../ui/settings/AdvancedSettingsScreen.kt | 4 - app/src/main/res/values/strings.xml | 2 +- 14 files changed, 137 insertions(+), 134 deletions(-) rename app/src/main/java/to/bitkit/{viewmodels => ui/screens/trezor}/TrezorViewModel.kt (98%) diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index 388557d96..6720b8366 100644 --- a/app/src/main/java/to/bitkit/ext/Context.kt +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -3,6 +3,7 @@ package to.bitkit.ext import android.app.Activity import android.app.ActivityManager import android.app.NotificationManager +import android.bluetooth.BluetoothManager import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -10,6 +11,7 @@ import android.content.Context.NOTIFICATION_SERVICE import android.content.ContextWrapper import android.content.Intent import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.hardware.usb.UsbManager import android.provider.Settings import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -31,6 +33,12 @@ val Context.clipboardManager: ClipboardManager val Context.activityManager: ActivityManager get() = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager +val Context.usbManager: UsbManager + get() = getSystemService(Context.USB_SERVICE) as UsbManager + +val Context.bluetoothManager: BluetoothManager + get() = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + // Permissions fun Context.requiresPermission(permission: String): Boolean = diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index f704d101d..692210adb 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -1,6 +1,7 @@ package to.bitkit.repositories import android.content.Context +import androidx.compose.runtime.Stable import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse @@ -36,23 +37,10 @@ import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport import to.bitkit.utils.Logger +import java.io.File import javax.inject.Inject import javax.inject.Singleton -data class TrezorState( - val isInitialized: Boolean = false, - val isScanning: Boolean = false, - val isConnecting: Boolean = false, - val isAutoReconnecting: Boolean = false, - val knownDevices: List = emptyList(), - val nearbyDevices: List = emptyList(), - val connectedDevice: TrezorFeatures? = null, - val connectedDeviceId: String? = null, - val lastAddress: TrezorAddressResponse? = null, - val lastPublicKey: TrezorPublicKeyResponse? = null, - val error: String? = null, -) - @Suppress("TooManyFunctions") @Singleton class TrezorRepo @Inject constructor( @@ -138,8 +126,8 @@ class TrezorRepo @Inject constructor( TrezorDeviceInfo( id = known.id, transportType = when (known.transportType) { - "bluetooth" -> com.synonym.bitkitcore.TrezorTransportType.BLUETOOTH - else -> com.synonym.bitkitcore.TrezorTransportType.USB + "bluetooth" -> TrezorTransportType.BLUETOOTH + else -> TrezorTransportType.USB }, name = known.name, path = known.path, @@ -370,7 +358,7 @@ class TrezorRepo @Inject constructor( val idMatch = knownDevices.firstNotNullOfOrNull { known -> scannedDevices.find { it.id == known.id } } - val match = usbDevice ?: idMatch ?: error("No known device found nearby") + val match = idMatch ?: usbDevice ?: error("No known device found nearby") connect(match.id).getOrThrow() } }.onSuccess { @@ -470,8 +458,8 @@ class TrezorRepo @Inject constructor( name = deviceInfo.name, path = deviceInfo.path, transportType = when (deviceInfo.transportType) { - com.synonym.bitkitcore.TrezorTransportType.BLUETOOTH -> "bluetooth" - com.synonym.bitkitcore.TrezorTransportType.USB -> "usb" + TrezorTransportType.BLUETOOTH -> "bluetooth" + TrezorTransportType.USB -> "usb" }, label = features.label ?: deviceInfo.label, model = features.model ?: deviceInfo.model, @@ -491,7 +479,7 @@ class TrezorRepo @Inject constructor( private fun saveKnownDevices(devices: List) { runCatching { - prefs.edit().putString(KEY_KNOWN_DEVICES, json.encodeToString(devices)).commit() + prefs.edit().putString(KEY_KNOWN_DEVICES, json.encodeToString(devices)).apply() }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } @@ -569,7 +557,7 @@ class TrezorRepo @Inject constructor( throw e } TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") - Logger.warn("Connection failed for $deviceId, retrying: ${e.message}", context = TAG) + Logger.warn("Connection failed for $deviceId, retrying", e, context = TAG) logCredentialFileState(deviceId, "BEFORE 2nd attempt") val result = trezorService.connect(deviceId) logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") @@ -580,8 +568,8 @@ class TrezorRepo @Inject constructor( private fun logCredentialFileState(deviceId: String, label: String) { val sanitizedId = deviceId.replace(":", "_").replace("/", "_") - val credDir = java.io.File(context.filesDir, "trezor-thp-credentials") - val credFile = java.io.File(credDir, "$sanitizedId.json") + val credDir = File(context.filesDir, "trezor-thp-credentials") + val credFile = File(credDir, "$sanitizedId.json") val exists = credFile.exists() val size = if (exists) credFile.length() else 0 TrezorDebugLog.log("CRED", "$label: file=$sanitizedId.json exists=$exists size=$size") @@ -593,6 +581,21 @@ class TrezorRepo @Inject constructor( } } +@Stable +data class TrezorState( + val isInitialized: Boolean = false, + val isScanning: Boolean = false, + val isConnecting: Boolean = false, + val isAutoReconnecting: Boolean = false, + val knownDevices: List = emptyList(), + val nearbyDevices: List = emptyList(), + val connectedDevice: TrezorFeatures? = null, + val connectedDeviceId: String? = null, + val lastAddress: TrezorAddressResponse? = null, + val lastPublicKey: TrezorPublicKeyResponse? = null, + val error: String? = null, +) + @Serializable data class KnownDevice( val id: String, diff --git a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt index 4d2033ed3..8ba7c0bf7 100644 --- a/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt +++ b/app/src/main/java/to/bitkit/services/TrezorDebugLog.kt @@ -3,6 +3,7 @@ package to.bitkit.services import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -17,18 +18,13 @@ object TrezorDebugLog { fun log(tag: String, msg: String) { val ts = fmt.format(Date()) val line = "$ts [$tag] $msg" - synchronized(this) { - val current = _lines.value.toMutableList() - current.add(line) - if (current.size > MAX_LINES) { - _lines.value = current.takeLast(MAX_LINES) - } else { - _lines.value = current - } + _lines.update { current -> + val updated = current + line + if (updated.size > MAX_LINES) updated.takeLast(MAX_LINES) else updated } } fun clear() { - _lines.value = emptyList() + _lines.update { emptyList() } } } diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 7d9ee5ccf..ec0ea67fc 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -34,6 +34,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import to.bitkit.ext.bluetoothManager +import to.bitkit.ext.usbManager import to.bitkit.utils.Logger import java.io.File import java.util.UUID @@ -90,26 +92,22 @@ class TrezorTransport @Inject constructor( private const val BLE_WRITE_RETRY_DELAY_MS = 100L private const val BLE_WRITE_INTER_DELAY_MS = 20L private const val BLE_CONNECTION_STABILIZATION_MS = 1000L + private const val BLE_CCCD_STABILIZATION_MS = 200L // BLE bonding constants private const val MAX_BOND_POLL_ATTEMPTS = 60 private const val BOND_POLL_INTERVAL_MS = 500L } - private val usbManager: UsbManager by lazy { - context.getSystemService(Context.USB_SERVICE) as UsbManager - } + private val usbManager: UsbManager by lazy { context.usbManager } - private val bluetoothManager: BluetoothManager by lazy { - context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - } + private val bluetoothManager: BluetoothManager by lazy { context.bluetoothManager } private val credentialDir: File by lazy { File(context.filesDir, "trezor-thp-credentials").also { it.mkdirs() } } - @Volatile - private var userInitiatedClose = false + private val userInitiatedCloseSet: MutableSet = ConcurrentHashMap.newKeySet() private val _externalDisconnect = MutableSharedFlow(extraBufferCapacity = 1) val externalDisconnect: SharedFlow = _externalDisconnect @@ -822,7 +820,7 @@ class TrezorTransport @Inject constructor( val connection = bleConnections.remove(path) ?: return TrezorTransportWriteResult(success = true, error = "") - userInitiatedClose = true + userInitiatedCloseSet.add(path) try { val disconnectLatch = CountDownLatch(1) bleConnections[path] = connection.copy(disconnectLatch = disconnectLatch) @@ -980,10 +978,9 @@ class TrezorTransport @Inject constructor( connection?.isConnected = false connection?.connectionLatch?.countDown() connection?.disconnectLatch?.countDown() - if (!userInitiatedClose) { + if (!userInitiatedCloseSet.remove(path)) { _externalDisconnect.tryEmit(path) } - userInitiatedClose = false } } } @@ -1109,8 +1106,6 @@ class TrezorTransport @Inject constructor( val path = "ble:${gatt.device.address}" val connection = bleConnections[path] ?: return - Thread.sleep(200) - val charUuid = descriptor.characteristic.uuid if (status == BluetoothGatt.GATT_SUCCESS) { Logger.info( @@ -1124,20 +1119,23 @@ class TrezorTransport @Inject constructor( ) } - // If this was the TX characteristic CCCD, also enable PUSH CCCD - if (descriptor.characteristic.uuid == NOTIFY_CHAR_UUID) { - val pushChar = gatt.getService(SERVICE_UUID)?.getCharacteristic(PUSH_CHAR_UUID) - if (!enablePushCccd(gatt, pushChar, path)) { - // PUSH CCCD not available or failed, signal ready now + // Delay subsequent GATT operations without blocking the callback thread + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + // If this was the TX characteristic CCCD, also enable PUSH CCCD + if (descriptor.characteristic.uuid == NOTIFY_CHAR_UUID) { + val pushChar = gatt.getService(SERVICE_UUID)?.getCharacteristic(PUSH_CHAR_UUID) + if (!enablePushCccd(gatt, pushChar, path)) { + // PUSH CCCD not available or failed, signal ready now + connection.isConnected = true + connection.connectionLatch?.countDown() + } + // If enablePushCccd returned true, onDescriptorWrite will fire again for PUSH + } else { + // This was the PUSH CCCD write (or other), signal connection ready connection.isConnected = true connection.connectionLatch?.countDown() } - // If enablePushCccd returned true, onDescriptorWrite will fire again for PUSH - } else { - // This was the PUSH CCCD write (or other), signal connection ready - connection.isConnected = true - connection.connectionLatch?.countDown() - } + }, BLE_CCCD_STABILIZATION_MS) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index 1e42b39c6..e7df95aac 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -30,7 +30,6 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard -import to.bitkit.viewmodels.TrezorUiState @Composable internal fun AddressSection( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index 9926250d0..82dcf2d98 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -33,7 +33,6 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard -import to.bitkit.viewmodels.TrezorUiState @Suppress("LongParameterList") @Composable diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt index 06b1cc64b..5a84d128a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -7,7 +7,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,6 +18,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.theme.Colors @@ -77,20 +78,21 @@ internal fun PairingCodeDialog( } }, confirmButton = { - TextButton( + TertiaryButton( + text = "Submit", onClick = { onSubmit(code) }, enabled = code.length == 6, - ) { - Text( - "Submit", - color = if (code.length == 6) Colors.Brand else Colors.White32, - ) - } + size = ButtonSize.Small, + fullWidth = false, + ) }, dismissButton = { - TextButton(onClick = onCancel) { - Text("Cancel", color = Colors.White64) - } + TertiaryButton( + text = "Cancel", + onClick = onCancel, + size = ButtonSize.Small, + fullWidth = false, + ) }, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index a5aedea25..421e5e323 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -31,7 +31,6 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard -import to.bitkit.viewmodels.TrezorUiState @Composable internal fun PublicKeySection( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 27e686522..9efd93db6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -35,8 +36,6 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard -import to.bitkit.viewmodels.SendStep -import to.bitkit.viewmodels.TrezorUiState private val textFieldColors @Composable get() = OutlinedTextFieldDefaults.colors( @@ -82,20 +81,24 @@ internal fun SendTransactionSection( onSortingStrategyChange = onSortingStrategyChange, onCompose = onCompose, ) - SendStep.REVIEW -> ReviewSection( - result = uiState.precomposedResult!!, - isDeviceConnected = isDeviceConnected, - isSigning = uiState.isSigning, - onSign = onSign, - onBack = onBack, - ) - SendStep.SIGNED -> SignedResultSection( - signedTx = uiState.signedTxResult!!, - isBroadcasting = uiState.isBroadcasting, - broadcastTxid = uiState.broadcastTxid, - onBroadcast = onBroadcast, - onReset = onReset, - ) + SendStep.REVIEW -> uiState.precomposedResult?.let { result -> + ReviewSection( + result = result, + isDeviceConnected = isDeviceConnected, + isSigning = uiState.isSigning, + onSign = onSign, + onBack = onBack, + ) + } + SendStep.SIGNED -> uiState.signedTxResult?.let { signedTx -> + SignedResultSection( + signedTx = signedTx, + isBroadcasting = uiState.isBroadcasting, + broadcastTxid = uiState.broadcastTxid, + onBroadcast = onBroadcast, + onReset = onReset, + ) + } } } } @@ -367,7 +370,7 @@ private fun SignedResultSection( ) HorizontalSpacer(8.dp) Icon( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_copy), + painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy raw tx", tint = Colors.Brand, modifier = Modifier @@ -416,7 +419,7 @@ private fun BroadcastResultCard(txid: String) { ) HorizontalSpacer(8.dp) Icon( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_copy), + painter = painterResource(R.drawable.ic_copy), contentDescription = "Copy txid", tint = Colors.Brand, modifier = Modifier diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index afb4a7d14..c808cb0ef 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -32,7 +32,6 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard -import to.bitkit.viewmodels.TrezorUiState @Composable internal fun SignMessageSection( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index c033e2b38..82727aa93 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -36,8 +36,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -69,8 +71,6 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard -import to.bitkit.viewmodels.TrezorUiState -import to.bitkit.viewmodels.TrezorViewModel private val bluetoothPermissions: List get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -492,7 +492,7 @@ private fun DebugLogSection() { color = Colors.White80, fontSize = 9.sp, lineHeight = 12.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + fontFamily = FontFamily.Monospace, ) } } @@ -570,7 +570,7 @@ private fun StatusRow(trezorState: TrezorState) { } @Composable -private fun StatusBadge(text: String, color: androidx.compose.ui.graphics.Color) { +private fun StatusBadge(text: String, color: Color) { Footnote( text = text, color = color, diff --git a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt similarity index 98% rename from app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt rename to app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 493d61a9e..166725d28 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -1,4 +1,4 @@ -package to.bitkit.viewmodels +package to.bitkit.ui.screens.trezor import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -8,6 +8,7 @@ import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorFeeLevel import com.synonym.bitkitcore.TrezorPrecomposeOutput import com.synonym.bitkitcore.TrezorPrecomposeParams +import com.synonym.bitkitcore.TrezorPrecomposedOutput import com.synonym.bitkitcore.TrezorPrecomposedResult import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignTxParams @@ -33,38 +34,6 @@ import to.bitkit.services.TrezorDebugLog import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject -data class TrezorUiState( - val selectedNetwork: TrezorCoinType = Env.network.toTrezorCoinType(), - val addressIndex: Int = 0, - val derivationPath: String = - "m/84'/${if (Env.network.toTrezorCoinType() == TrezorCoinType.BITCOIN) "0" else "1"}'/0'/0/0", - val messageToSign: String = "Hello, Trezor!", - val lastSignature: String? = null, - val lastSigningAddress: String? = null, - val isSigningMessage: Boolean = false, - val isGettingAddress: Boolean = false, - val isGettingPublicKey: Boolean = false, - val isVerifyingMessage: Boolean = false, - val lookupInput: String = "", - val isLookingUp: Boolean = false, - val accountInfoResult: AccountInfoResult? = null, - val addressInfoResult: SingleAddressInfoResult? = null, - val sendAddress: String = "", - val sendAmountSats: String = "", - val sendFeeRate: String = "2", - val isSendMax: Boolean = false, - val isComposing: Boolean = false, - val isSigning: Boolean = false, - val precomposedResult: TrezorPrecomposedResult.Final? = null, - val signedTxResult: TrezorSignedTx? = null, - val sendStep: SendStep = SendStep.FORM, - val sortingStrategy: TrezorSortingStrategy = TrezorSortingStrategy.BIP69, - val isBroadcasting: Boolean = false, - val broadcastTxid: String? = null, -) - -enum class SendStep { FORM, REVIEW, SIGNED } - @Suppress("TooManyFunctions") @HiltViewModel class TrezorViewModel @Inject constructor( @@ -520,15 +489,15 @@ class TrezorViewModel @Inject constructor( } finalResult.outputs.forEach { when (it) { - is com.synonym.bitkitcore.TrezorPrecomposedOutput.Payment -> + is TrezorPrecomposedOutput.Payment -> TrezorDebugLog.log("COMPOSE", " output(payment): addr=${it.address}, amount=${it.amount}") - is com.synonym.bitkitcore.TrezorPrecomposedOutput.Change -> + is TrezorPrecomposedOutput.Change -> TrezorDebugLog.log( "COMPOSE", " output(change): addr=${it.address}, " + "amount=${it.amount}, path=${it.path}" ) - is com.synonym.bitkitcore.TrezorPrecomposedOutput.OpReturn -> + is TrezorPrecomposedOutput.OpReturn -> TrezorDebugLog.log("COMPOSE", " output(opreturn): ${it.dataHex}") } } @@ -680,3 +649,35 @@ class TrezorViewModel @Inject constructor( } } } + +data class TrezorUiState( + val selectedNetwork: TrezorCoinType = Env.network.toTrezorCoinType(), + val addressIndex: Int = 0, + val derivationPath: String = + "m/84'/${if (Env.network.toTrezorCoinType() == TrezorCoinType.BITCOIN) "0" else "1"}'/0'/0/0", + val messageToSign: String = "Hello, Trezor!", + val lastSignature: String? = null, + val lastSigningAddress: String? = null, + val isSigningMessage: Boolean = false, + val isGettingAddress: Boolean = false, + val isGettingPublicKey: Boolean = false, + val isVerifyingMessage: Boolean = false, + val lookupInput: String = "", + val isLookingUp: Boolean = false, + val accountInfoResult: AccountInfoResult? = null, + val addressInfoResult: SingleAddressInfoResult? = null, + val sendAddress: String = "", + val sendAmountSats: String = "", + val sendFeeRate: String = "2", + val isSendMax: Boolean = false, + val isComposing: Boolean = false, + val isSigning: Boolean = false, + val precomposedResult: TrezorPrecomposedResult.Final? = null, + val signedTxResult: TrezorSignedTx? = null, + val sendStep: SendStep = SendStep.FORM, + val sortingStrategy: TrezorSortingStrategy = TrezorSortingStrategy.BIP69, + val isBroadcasting: Boolean = false, + val broadcastTxid: String? = null, +) + +enum class SendStep { FORM, REVIEW, SIGNED } diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index 4a2703258..89e678557 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -66,9 +66,6 @@ fun AdvancedSettingsScreen( onAddressViewerClick = { navController.navigate(Routes.AddressViewer) }, - onSweepFundsClick = { - navController.navigate(Routes.SweepNav) - }, onTrezorClick = { navController.navigate(Routes.Trezor) }, @@ -95,7 +92,6 @@ private fun Content( onElectrumServerClick: () -> Unit = {}, onRgsServerClick: () -> Unit = {}, onAddressViewerClick: () -> Unit = {}, - onSweepFundsClick: () -> Unit = {}, onTrezorClick: () -> Unit = {}, onSuggestionsResetClick: () -> Unit = {}, onResetSuggestionsDialogConfirm: () -> Unit = {}, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3587af49a..e0ffebc06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -572,10 +572,10 @@ Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview. Reset Suggestions? Rapid-Gossip-Sync + Hardware Wallet Networks Other Payments - Hardware Wallet Reset Suggestions Trezor Advanced From 88795b646c970286f8031630bb5ca2627c5e3dcf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Mar 2026 11:31:47 -0300 Subject: [PATCH 15/48] refactor: replace mising components fix the closet references --- .../ui/screens/trezor/AddressSection.kt | 13 ++------ .../ui/screens/trezor/BalanceLookupSection.kt | 13 ++------ .../ui/screens/trezor/PairingCodeDialog.kt | 17 +++++----- .../ui/screens/trezor/PublicKeySection.kt | 19 +++--------- .../screens/trezor/SendTransactionSection.kt | 31 +++++-------------- .../ui/screens/trezor/SignMessageSection.kt | 11 ++----- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 7 +---- 7 files changed, 31 insertions(+), 80 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index 1e42b39c6..79eaae3bd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -10,15 +10,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize @@ -46,11 +43,9 @@ internal fun AddressSection( ) VerticalSpacer(8.dp) - Text( + Footnote( text = "Path: ${uiState.derivationPath}", color = Colors.White50, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, ) VerticalSpacer(12.dp) @@ -93,12 +88,10 @@ internal fun AddressSection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( + Footnote( text = response.address, color = Colors.Brand, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) Icon( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index 9926250d0..a98ad8907 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -11,15 +11,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountUtxo import com.synonym.bitkitcore.SingleAddressInfoResult @@ -63,7 +60,7 @@ internal fun BalanceLookupSection( OutlinedTextField( value = uiState.lookupInput, onValueChange = onInputChange, - label = { Text("Address or xpub", color = Colors.White50) }, + label = { Footnote("Address or xpub", color = Colors.White50) }, colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Colors.White, unfocusedTextColor = Colors.White, @@ -180,11 +177,9 @@ private fun AddressInfoResultView(result: SingleAddressInfoResult) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Text( + Footnote( text = result.address, color = Colors.Brand, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) @@ -226,11 +221,9 @@ private fun UtxoRow(utxo: AccountUtxo) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Text( + Footnote( text = "${utxo.txid.take(8)}...${utxo.txid.takeLast(8)}:${utxo.vout}", color = Colors.Brand, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt index 06b1cc64b..501a1819e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -15,10 +14,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.theme.Colors @@ -33,18 +34,16 @@ internal fun PairingCodeDialog( onDismissRequest = onCancel, containerColor = Colors.Gray5, title = { - Text( + BodySSB( text = "Enter Pairing Code", color = Colors.White, - fontWeight = FontWeight.SemiBold, ) }, text = { Column { - Text( + Caption( text = "Enter the 6-digit code shown on your Trezor device:", color = Colors.White80, - fontSize = 14.sp, ) VerticalSpacer(16.dp) OutlinedTextField( @@ -55,7 +54,7 @@ internal fun PairingCodeDialog( } }, placeholder = { - Text("000000", color = Colors.White32) + Footnote("000000", color = Colors.White32) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -81,7 +80,7 @@ internal fun PairingCodeDialog( onClick = { onSubmit(code) }, enabled = code.length == 6, ) { - Text( + Footnote( "Submit", color = if (code.length == 6) Colors.Brand else Colors.White32, ) @@ -89,7 +88,7 @@ internal fun PairingCodeDialog( }, dismissButton = { TextButton(onClick = onCancel) { - Text("Cancel", color = Colors.White64) + Footnote("Cancel", color = Colors.White64) } }, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index a5aedea25..3eb4e3966 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -10,16 +10,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize @@ -50,11 +47,9 @@ internal fun PublicKeySection( ) VerticalSpacer(8.dp) - Text( + Footnote( text = "Account path: $accountPath", color = Colors.White50, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, ) VerticalSpacer(12.dp) @@ -98,12 +93,10 @@ internal fun PublicKeySection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( + Footnote( text = response.xpub, color = Colors.Brand, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) Icon( @@ -130,12 +123,10 @@ internal fun PublicKeySection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( + Footnote( text = response.publicKey, color = Colors.Brand, - fontSize = 12.sp, - fontFamily = FontFamily.Monospace, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) Icon( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 27e686522..0c1830d53 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -12,15 +12,12 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.synonym.bitkitcore.TrezorPrecomposedOutput import com.synonym.bitkitcore.TrezorPrecomposedResult import com.synonym.bitkitcore.TrezorSignedTx @@ -114,7 +111,7 @@ private fun ComposeForm( OutlinedTextField( value = uiState.sendAddress, onValueChange = onAddressChange, - label = { Text("Destination address", color = Colors.White50) }, + label = { Footnote("Destination address", color = Colors.White50) }, colors = textFieldColors, maxLines = 3, modifier = Modifier.fillMaxWidth(), @@ -130,7 +127,7 @@ private fun ComposeForm( OutlinedTextField( value = if (uiState.isSendMax) "MAX" else uiState.sendAmountSats, onValueChange = onAmountChange, - label = { Text("Amount (sats)", color = Colors.White50) }, + label = { Footnote("Amount (sats)", color = Colors.White50) }, colors = textFieldColors, enabled = !uiState.isSendMax, singleLine = true, @@ -154,7 +151,7 @@ private fun ComposeForm( OutlinedTextField( value = uiState.sendFeeRate, onValueChange = onFeeRateChange, - label = { Text("Fee rate (sat/vB)", color = Colors.White50) }, + label = { Footnote("Fee rate (sat/vB)", color = Colors.White50) }, colors = textFieldColors, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -241,11 +238,9 @@ private fun ReviewSection( VerticalSpacer(4.dp) result.inputs.forEach { input -> ResultCard { - Text( + Footnote( text = "${input.txid.take(8)}...${input.txid.takeLast(8)}:${input.vout}", color = Colors.Brand, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, ) InfoRow("Amount", "${input.amount} sats") InfoRow("Path", input.path) @@ -301,21 +296,17 @@ private fun OutputCard(output: TrezorPrecomposedOutput) { when (output) { is TrezorPrecomposedOutput.Payment -> { InfoRow("Type", "Payment") - Text( + Footnote( text = output.address, color = Colors.Brand, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, ) InfoRow("Amount", "${output.amount} sats") } is TrezorPrecomposedOutput.Change -> { InfoRow("Type", "Change") - Text( + Footnote( text = output.address, color = Colors.White64, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, ) InfoRow("Amount", "${output.amount} sats") InfoRow("Path", output.path) @@ -357,12 +348,9 @@ private fun SignedResultSection( verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth(), ) { - Text( + Footnote( text = signedTx.serializedTx, color = Colors.White, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - lineHeight = 14.sp, modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) @@ -424,12 +412,9 @@ private fun BroadcastResultCard(txid: String) { .clickableAlpha(onClick = onCopyTxid), ) } - Text( + Footnote( text = txid, color = Colors.Brand, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, - lineHeight = 14.sp, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index afb4a7d14..d05053d01 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -12,16 +12,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Footnote @@ -51,7 +48,7 @@ internal fun SignMessageSection( OutlinedTextField( value = uiState.messageToSign, onValueChange = onMessageChange, - label = { Text("Message", color = Colors.White50) }, + label = { Footnote("Message", color = Colors.White50) }, modifier = Modifier.fillMaxWidth(), colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Colors.White, @@ -105,14 +102,12 @@ internal fun SignMessageSection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( + Footnote( text = sig, color = Colors.Brand, - fontSize = 10.sp, - fontFamily = FontFamily.Monospace, maxLines = 3, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) HorizontalSpacer(8.dp) Icon( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index c033e2b38..2fa2df0c5 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,7 +39,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController @@ -487,12 +485,9 @@ private fun DebugLogSection() { .padding(8.dp), ) { items(debugLines) { line -> - Text( + Footnote( text = line, color = Colors.White80, - fontSize = 9.sp, - lineHeight = 12.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, ) } } From 9d9d09d70eec498f460d9b923e97a9c1478337e4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 9 Mar 2026 11:56:22 -0300 Subject: [PATCH 16/48] fix: implement text style patterns using existing screens as reference --- .../ui/screens/trezor/AddressSection.kt | 11 +++--- .../ui/screens/trezor/BalanceLookupSection.kt | 15 ++++---- .../screens/trezor/ConnectedDeviceSection.kt | 6 +-- .../ui/screens/trezor/DeviceListSection.kt | 8 ++-- .../ui/screens/trezor/PairingCodeDialog.kt | 9 +++-- .../ui/screens/trezor/PublicKeySection.kt | 15 ++++---- .../screens/trezor/SendTransactionSection.kt | 38 ++++++++++--------- .../ui/screens/trezor/SignMessageSection.kt | 11 +++--- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 27 ++++++------- 9 files changed, 74 insertions(+), 66 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index 79eaae3bd..adddc3151 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -19,7 +19,8 @@ import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -37,13 +38,13 @@ internal fun AddressSection( onIncrementIndex: () -> Unit, ) { Column { - Footnote( + Caption13Up( text = "Address Generation", color = Colors.White64, ) VerticalSpacer(8.dp) - Footnote( + Caption( text = "Path: ${uiState.derivationPath}", color = Colors.White50, ) @@ -75,7 +76,7 @@ internal fun AddressSection( val onCopyAddress = copyToClipboard(text = response.address, label = "Address") Column { VerticalSpacer(12.dp) - Footnote( + Caption( text = "Address:", color = Colors.White50, ) @@ -88,7 +89,7 @@ internal fun AddressSection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Footnote( + Caption( text = response.address, color = Colors.Brand, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index a98ad8907..4851477fc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -23,7 +23,8 @@ import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.VerticalSpacer @@ -51,7 +52,7 @@ internal fun BalanceLookupSection( onResetSend: () -> Unit, ) { Column { - Footnote( + Caption13Up( text = "Balance Lookup", color = Colors.White64, ) @@ -60,7 +61,7 @@ internal fun BalanceLookupSection( OutlinedTextField( value = uiState.lookupInput, onValueChange = onInputChange, - label = { Footnote("Address or xpub", color = Colors.White50) }, + label = { Caption("Address or xpub", color = Colors.White50) }, colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Colors.White, unfocusedTextColor = Colors.White, @@ -136,7 +137,7 @@ private fun AccountInfoResultView( if (result.account.utxo.isNotEmpty()) { VerticalSpacer(8.dp) - Footnote( + Caption13Up( text = "UTXOs (${result.account.utxo.size})", color = Colors.White64, ) @@ -177,7 +178,7 @@ private fun AddressInfoResultView(result: SingleAddressInfoResult) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Footnote( + Caption( text = result.address, color = Colors.Brand, modifier = Modifier.weight(1f), @@ -200,7 +201,7 @@ private fun AddressInfoResultView(result: SingleAddressInfoResult) { if (result.utxos.isNotEmpty()) { VerticalSpacer(8.dp) - Footnote( + Caption13Up( text = "UTXOs (${result.utxos.size})", color = Colors.White64, ) @@ -221,7 +222,7 @@ private fun UtxoRow(utxo: AccountUtxo) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Footnote( + Caption( text = "${utxo.txid.take(8)}...${utxo.txid.takeLast(8)}:${utxo.vout}", color = Colors.Brand, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt index dd1e8c74a..d1f8a4b20 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorFeatures -import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.Caption import to.bitkit.ui.theme.Colors @Composable @@ -43,11 +43,11 @@ internal fun InfoRow(label: String, value: String) { .padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween ) { - Footnote( + Caption( text = label, color = Colors.White50, ) - Footnote( + Caption( text = value, color = Colors.White, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index c91004e5b..a3183da76 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -21,8 +21,8 @@ import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R import to.bitkit.repositories.KnownDevice +import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB -import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.Colors @@ -61,7 +61,7 @@ internal fun DeviceCard( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Footnote( + Caption( text = when (device.transportType) { TrezorTransportType.USB -> "USB" TrezorTransportType.BLUETOOTH -> "Bluetooth" @@ -116,11 +116,11 @@ internal fun KnownDeviceCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Footnote( + Caption( text = if (device.transportType == "bluetooth") "Bluetooth" else "USB", color = Colors.White50, ) - Footnote( + Caption( text = if (isConnected) "Connected" else "Disconnected", color = if (isConnected) Colors.Green else Colors.White32, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt index 501a1819e..9d7efa90a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -17,9 +17,10 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.theme.Colors @@ -34,7 +35,7 @@ internal fun PairingCodeDialog( onDismissRequest = onCancel, containerColor = Colors.Gray5, title = { - BodySSB( + Title( text = "Enter Pairing Code", color = Colors.White, ) @@ -80,7 +81,7 @@ internal fun PairingCodeDialog( onClick = { onSubmit(code) }, enabled = code.length == 6, ) { - Footnote( + CaptionB( "Submit", color = if (code.length == 6) Colors.Brand else Colors.White32, ) @@ -88,7 +89,7 @@ internal fun PairingCodeDialog( }, dismissButton = { TextButton(onClick = onCancel) { - Footnote("Cancel", color = Colors.White64) + CaptionB("Cancel", color = Colors.White64) } }, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index 3eb4e3966..bf1653dd3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -20,7 +20,8 @@ import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.repositories.TrezorState import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -41,13 +42,13 @@ internal fun PublicKeySection( } Column { - Footnote( + Caption13Up( text = "Public Key (xpub)", color = Colors.White64, ) VerticalSpacer(8.dp) - Footnote( + Caption( text = "Account path: $accountPath", color = Colors.White50, ) @@ -80,7 +81,7 @@ internal fun PublicKeySection( val onCopyPublicKey = copyToClipboard(text = response.publicKey, label = "Public Key") Column { VerticalSpacer(12.dp) - Footnote( + Caption( text = "xpub:", color = Colors.White50, ) @@ -93,7 +94,7 @@ internal fun PublicKeySection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Footnote( + Caption( text = response.xpub, color = Colors.Brand, modifier = Modifier.weight(1f), @@ -110,7 +111,7 @@ internal fun PublicKeySection( } VerticalSpacer(12.dp) - Footnote( + Caption( text = "Public Key:", color = Colors.White50, ) @@ -123,7 +124,7 @@ internal fun PublicKeySection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Footnote( + Caption( text = response.publicKey, color = Colors.Brand, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 0c1830d53..0f150856f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -24,7 +24,9 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -63,7 +65,7 @@ internal fun SendTransactionSection( modifier: Modifier = Modifier, ) { Column(modifier = modifier) { - Footnote( + Caption13Up( text = "Send Transaction", color = Colors.White64, ) @@ -111,7 +113,7 @@ private fun ComposeForm( OutlinedTextField( value = uiState.sendAddress, onValueChange = onAddressChange, - label = { Footnote("Destination address", color = Colors.White50) }, + label = { Caption("Destination address", color = Colors.White50) }, colors = textFieldColors, maxLines = 3, modifier = Modifier.fillMaxWidth(), @@ -127,7 +129,7 @@ private fun ComposeForm( OutlinedTextField( value = if (uiState.isSendMax) "MAX" else uiState.sendAmountSats, onValueChange = onAmountChange, - label = { Footnote("Amount (sats)", color = Colors.White50) }, + label = { Caption("Amount (sats)", color = Colors.White50) }, colors = textFieldColors, enabled = !uiState.isSendMax, singleLine = true, @@ -135,7 +137,7 @@ private fun ComposeForm( modifier = Modifier.weight(1f), ) val maxColor = if (uiState.isSendMax) Colors.Brand else Colors.White32 - Footnote( + CaptionB( text = "MAX", color = maxColor, modifier = Modifier @@ -151,7 +153,7 @@ private fun ComposeForm( OutlinedTextField( value = uiState.sendFeeRate, onValueChange = onFeeRateChange, - label = { Footnote("Fee rate (sat/vB)", color = Colors.White50) }, + label = { Caption("Fee rate (sat/vB)", color = Colors.White50) }, colors = textFieldColors, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -160,7 +162,7 @@ private fun ComposeForm( VerticalSpacer(12.dp) - Footnote( + Caption13Up( text = "Coin Selection", color = Colors.White64, ) @@ -198,7 +200,7 @@ private fun SortingStrategyRow( TrezorSortingStrategy.entries.forEach { strategy -> val isSelected = strategy == selected val color = if (isSelected) Colors.Brand else Colors.White32 - Footnote( + CaptionB( text = labels[strategy] ?: strategy.name, color = color, modifier = Modifier @@ -231,14 +233,14 @@ private fun ReviewSection( if (result.inputs.isNotEmpty()) { VerticalSpacer(8.dp) - Footnote( + Caption13Up( text = "Inputs (${result.inputs.size})", color = Colors.White64, ) VerticalSpacer(4.dp) result.inputs.forEach { input -> ResultCard { - Footnote( + Caption( text = "${input.txid.take(8)}...${input.txid.takeLast(8)}:${input.vout}", color = Colors.Brand, ) @@ -251,7 +253,7 @@ private fun ReviewSection( if (result.outputs.isNotEmpty()) { VerticalSpacer(8.dp) - Footnote( + Caption13Up( text = "Outputs (${result.outputs.size})", color = Colors.White64, ) @@ -285,7 +287,7 @@ private fun ReviewSection( if (!isDeviceConnected) { VerticalSpacer(4.dp) - Footnote(text = "Connect a Trezor device to sign") + Caption(text = "Connect a Trezor device to sign") } } } @@ -296,7 +298,7 @@ private fun OutputCard(output: TrezorPrecomposedOutput) { when (output) { is TrezorPrecomposedOutput.Payment -> { InfoRow("Type", "Payment") - Footnote( + Caption( text = output.address, color = Colors.Brand, ) @@ -304,7 +306,7 @@ private fun OutputCard(output: TrezorPrecomposedOutput) { } is TrezorPrecomposedOutput.Change -> { InfoRow("Type", "Change") - Footnote( + Caption( text = output.address, color = Colors.White64, ) @@ -337,7 +339,7 @@ private fun SignedResultSection( VerticalSpacer(8.dp) - Footnote( + Caption13Up( text = "Raw Transaction Hex", color = Colors.White64, ) @@ -348,7 +350,7 @@ private fun SignedResultSection( verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth(), ) { - Footnote( + Caption( text = signedTx.serializedTx, color = Colors.White, modifier = Modifier.weight(1f), @@ -398,7 +400,7 @@ private fun BroadcastResultCard(txid: String) { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Footnote( + Caption13Up( text = "Broadcast TXID", color = Colors.White64, ) @@ -412,7 +414,7 @@ private fun BroadcastResultCard(txid: String) { .clickableAlpha(onClick = onCopyTxid), ) } - Footnote( + Caption( text = txid, color = Colors.Brand, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index d05053d01..219362e93 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -21,7 +21,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ui.components.ButtonSize -import to.bitkit.ui.components.Footnote +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -39,7 +40,7 @@ internal fun SignMessageSection( onVerifyMessage: () -> Unit, ) { Column { - Footnote( + Caption13Up( text = "Sign Message", color = Colors.White64, ) @@ -48,7 +49,7 @@ internal fun SignMessageSection( OutlinedTextField( value = uiState.messageToSign, onValueChange = onMessageChange, - label = { Footnote("Message", color = Colors.White50) }, + label = { Caption("Message", color = Colors.White50) }, modifier = Modifier.fillMaxWidth(), colors = OutlinedTextFieldDefaults.colors( focusedTextColor = Colors.White, @@ -89,7 +90,7 @@ internal fun SignMessageSection( val onCopySignature = copyToClipboard(text = sig, label = "Signature") Column { VerticalSpacer(12.dp) - Footnote( + Caption( text = "Signature:", color = Colors.White50, ) @@ -102,7 +103,7 @@ internal fun SignMessageSection( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Footnote( + Caption( text = sig, color = Colors.Brand, maxLines = 3, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 2fa2df0c5..e1ce20616 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -52,13 +52,14 @@ import to.bitkit.R import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState import to.bitkit.services.TrezorDebugLog -import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.ButtonSize +import to.bitkit.ui.components.Caption +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -197,7 +198,7 @@ private fun TrezorContent( .imePadding() .verticalScroll(rememberScrollState()) ) { - Text13Up("TREZOR TEST", color = Colors.White64) + Caption13Up("TREZOR TEST", color = Colors.White64) VerticalSpacer(8.dp) NetworkSelectorRow( selectedNetwork = uiState.selectedNetwork, @@ -235,7 +236,7 @@ private fun TrezorContent( ) { Column { VerticalSpacer(16.dp) - Footnote( + Caption13Up( text = "My Devices (${trezorState.knownDevices.size})", color = Colors.White64, ) @@ -265,7 +266,7 @@ private fun TrezorContent( ) { Column { VerticalSpacer(16.dp) - Footnote( + Caption13Up( text = "New Devices (${trezorState.nearbyDevices.size})", color = Colors.White64, ) @@ -293,7 +294,7 @@ private fun TrezorContent( trezorState.connectedDevice?.let { features -> Column { VerticalSpacer(16.dp) - Footnote( + Caption13Up( text = "Connected Device", color = Colors.White64, ) @@ -345,7 +346,7 @@ private fun TrezorContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top ) { - Footnote( + Caption( text = error, color = Colors.Red, modifier = Modifier.weight(1f), @@ -411,7 +412,7 @@ private fun NetworkSelectorRow( TrezorCoinType.entries.filter { it != TrezorCoinType.SIGNET }.forEach { network -> val isSelected = network == selectedNetwork val color = if (isSelected) Colors.Brand else Colors.White32 - Footnote( + CaptionB( text = network.name, color = color, modifier = Modifier @@ -510,7 +511,7 @@ private fun StatusRow(trezorState: TrezorState) { modifier = Modifier.size(20.dp) ) HorizontalSpacer(8.dp) - BodySSB( + CaptionB( text = "Trezor", color = Colors.White, ) @@ -525,7 +526,7 @@ private fun StatusRow(trezorState: TrezorState) { color = Colors.Brand ) HorizontalSpacer(8.dp) - Footnote("Reconnecting...", color = Colors.White64) + Caption("Reconnecting...", color = Colors.White64) } trezorState.isScanning -> { @@ -535,7 +536,7 @@ private fun StatusRow(trezorState: TrezorState) { color = Colors.Brand ) HorizontalSpacer(8.dp) - Footnote("Scanning...", color = Colors.White64) + Caption("Scanning...", color = Colors.White64) } trezorState.isConnecting -> { @@ -545,7 +546,7 @@ private fun StatusRow(trezorState: TrezorState) { color = Colors.Brand ) HorizontalSpacer(8.dp) - Footnote("Connecting...", color = Colors.White64) + Caption("Connecting...", color = Colors.White64) } trezorState.connectedDevice != null -> { @@ -566,7 +567,7 @@ private fun StatusRow(trezorState: TrezorState) { @Composable private fun StatusBadge(text: String, color: androidx.compose.ui.graphics.Color) { - Footnote( + Caption( text = text, color = color, modifier = Modifier From eca363ae30f451df7c5e746c55304b8d3660475d Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 9 Mar 2026 11:10:45 -0400 Subject: [PATCH 17/48] fix: address pr #792 review feedback --- app/src/main/AndroidManifest.xml | 3 +- .../java/to/bitkit/repositories/TrezorRepo.kt | 499 ++++++++++-------- .../to/bitkit/services/TrezorTransport.kt | 113 ++-- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 3 +- .../ui/settings/AdvancedSettingsScreen.kt | 4 +- app/src/main/res/values/strings.xml | 2 - 6 files changed, 335 insertions(+), 289 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c722d2671..6a143183d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,7 +37,8 @@ android:maxSdkVersion="30" /> - + = runCatching { - val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json" - Logger.debug("Initializing Trezor with credential path: $credentialPath", context = TAG) - trezorService.initialize(credentialPath) - val known = loadKnownDevices() - _state.update { it.copy(isInitialized = true, knownDevices = known, error = null) } - }.onFailure { e -> - Logger.error("Trezor init failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + suspend fun initialize(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + runCatching { + val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json" + Logger.debug("Initializing Trezor with credential path: '$credentialPath'", context = TAG) + trezorService.initialize(credentialPath) + val known = loadKnownDevices() + _state.update { it.copy(isInitialized = true, knownDevices = known, error = null) } + }.onFailure { e -> + Logger.error("Trezor init failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } - suspend fun scan(): Result> = runCatching { - _state.update { it.copy(isScanning = true, error = null) } - val devices = trezorService.scan() - val knownIds = _state.value.knownDevices.map { it.id }.toSet() - val nearby = devices.filter { it.id !in knownIds } - _state.update { it.copy(isScanning = false, nearbyDevices = nearby) } - devices - }.onFailure { e -> - Logger.error("Trezor scan failed", e, context = TAG) - _state.update { it.copy(isScanning = false, error = e.message) } - } - - suspend fun listDevices(): Result> = runCatching { - val devices = trezorService.listDevices() - val knownIds = _state.value.knownDevices.map { it.id }.toSet() - val nearby = devices.filter { it.id !in knownIds } - _state.update { it.copy(nearbyDevices = nearby) } - devices - }.onFailure { e -> - Logger.error("Trezor listDevices failed", e, context = TAG) - _state.update { it.copy(error = e.message) } - } - - suspend fun connect(deviceId: String): Result = runCatching { - _state.update { it.copy(isConnecting = true, error = null) } - TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId") - val features = connectWithThpRetry(deviceId) - TrezorDebugLog.log("CONNECT", "connect() succeeded: label=${features.label}, model=${features.model}") - val deviceInfo = _state.value.nearbyDevices.find { it.id == deviceId } - ?: _state.value.knownDevices.find { it.id == deviceId }?.let { known -> - TrezorDeviceInfo( - id = known.id, - transportType = when (known.transportType) { - "bluetooth" -> TrezorTransportType.BLUETOOTH - else -> TrezorTransportType.USB - }, - name = known.name, - path = known.path, - label = known.label, - model = known.model, - isBootloader = false, + suspend fun scan(): Result> = withContext(bgDispatcher) { + runCatching { + _state.update { it.copy(isScanning = true, error = null) } + val devices = trezorService.scan() + val knownIds = _state.value.knownDevices.map { it.id }.toSet() + val nearby = devices.filter { it.id !in knownIds } + _state.update { it.copy(isScanning = false, nearbyDevices = nearby) } + devices + }.onFailure { e -> + Logger.error("Trezor scan failed", e, context = TAG) + _state.update { it.copy(isScanning = false, error = e.message) } + } + } + + suspend fun listDevices(): Result> = withContext(bgDispatcher) { + runCatching { + val devices = trezorService.listDevices() + val knownIds = _state.value.knownDevices.map { it.id }.toSet() + val nearby = devices.filter { it.id !in knownIds } + _state.update { it.copy(nearbyDevices = nearby) } + devices + }.onFailure { e -> + Logger.error("Trezor listDevices failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } + } + + suspend fun connect(deviceId: String): Result = withContext(bgDispatcher) { + runCatching { + _state.update { it.copy(isConnecting = true, error = null) } + TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId") + val features = connectWithThpRetry(deviceId) + TrezorDebugLog.log("CONNECT", "connect() succeeded: label=${features.label}, model=${features.model}") + val deviceInfo = _state.value.nearbyDevices.find { it.id == deviceId } + ?: _state.value.knownDevices.find { it.id == deviceId }?.let { known -> + TrezorDeviceInfo( + id = known.id, + transportType = when (known.transportType) { + "bluetooth" -> TrezorTransportType.BLUETOOTH + else -> TrezorTransportType.USB + }, + name = known.name, + path = known.path, + label = known.label, + model = known.model, + isBootloader = false, + ) + } + if (deviceInfo != null) { + addOrUpdateKnownDevice(deviceInfo, features) + } + _state.update { + it.copy( + isConnecting = false, + connectedDevice = features, + connectedDeviceId = deviceId, + nearbyDevices = it.nearbyDevices.filter { d -> d.id != deviceId }, ) } - if (deviceInfo != null) { - addOrUpdateKnownDevice(deviceInfo, features) - } - _state.update { - it.copy( - isConnecting = false, - connectedDevice = features, - connectedDeviceId = deviceId, - nearbyDevices = it.nearbyDevices.filter { d -> d.id != deviceId }, - ) + features + }.onFailure { e -> + Logger.error("Trezor connect failed", e, context = TAG) + _state.update { it.copy(isConnecting = false, error = e.message) } } - features - }.onFailure { e -> - Logger.error("Trezor connect failed", e, context = TAG) - _state.update { it.copy(isConnecting = false, error = e.message) } } suspend fun getAddress( @@ -158,163 +170,185 @@ class TrezorRepo @Inject constructor( showOnTrezor: Boolean = false, scriptType: TrezorScriptType? = TrezorScriptType.SPEND_WITNESS, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = runCatching { - ensureConnected() - val response = trezorService.getAddress( - path = path, - coin = coin, - showOnTrezor = showOnTrezor, - scriptType = scriptType, - ) - _state.update { it.copy(lastAddress = response, error = null) } - response - }.onFailure { e -> - Logger.error("Trezor getAddress failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + ensureConnected() + val response = trezorService.getAddress( + path = path, + coin = coin, + showOnTrezor = showOnTrezor, + scriptType = scriptType, + ) + _state.update { it.copy(lastAddress = response, error = null) } + response + }.onFailure { e -> + Logger.error("Trezor getAddress failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } suspend fun getPublicKey( path: String = "m/84'/0'/0'", showOnTrezor: Boolean = false, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = runCatching { - ensureConnected() - val response = trezorService.getPublicKey( - path = path, - coin = coin, - showOnTrezor = showOnTrezor, - ) - _state.update { it.copy(lastPublicKey = response, error = null) } - response - }.onFailure { e -> - Logger.error("Trezor getPublicKey failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + ensureConnected() + val response = trezorService.getPublicKey( + path = path, + coin = coin, + showOnTrezor = showOnTrezor, + ) + _state.update { it.copy(lastPublicKey = response, error = null) } + response + }.onFailure { e -> + Logger.error("Trezor getPublicKey failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } suspend fun getAccountInfo( extendedKey: String, network: TrezorCoinType = Env.network.toTrezorCoinType(), - ): Result = runCatching { - trezorService.getAccountInfo( - extendedKey = extendedKey, - electrumUrl = electrumUrlForNetwork(network), - network = keyFormatNetwork(network), - ) - }.onFailure { e -> - Logger.error("Trezor getAccountInfo failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + trezorService.getAccountInfo( + extendedKey = extendedKey, + electrumUrl = electrumUrlForNetwork(network), + network = keyFormatNetwork(network), + ) + }.onFailure { e -> + Logger.error("Trezor getAccountInfo failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } suspend fun getAddressInfo( address: String, network: TrezorCoinType = Env.network.toTrezorCoinType(), - ): Result = runCatching { - trezorService.getAddressInfo( - address = address, - electrumUrl = electrumUrlForNetwork(network), - network = keyFormatNetwork(network), - ) - }.onFailure { e -> - Logger.error("Trezor getAddressInfo failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + trezorService.getAddressInfo( + address = address, + electrumUrl = electrumUrlForNetwork(network), + network = keyFormatNetwork(network), + ) + }.onFailure { e -> + Logger.error("Trezor getAddressInfo failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } suspend fun precomposeTransaction( params: TrezorPrecomposeParams, - ): Result> = runCatching { - trezorService.precomposeTransaction(params = params) - }.onFailure { - Logger.error("Trezor precomposeTransaction failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } + ): Result> = withContext(bgDispatcher) { + runCatching { + trezorService.precomposeTransaction(params = params) + }.onFailure { + Logger.error("Trezor precomposeTransaction failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } } suspend fun convertToSignParams( inputs: List, outputs: List, coin: TrezorCoinType?, - ): Result = runCatching { - trezorService.precomposedToSignParams( - inputs = inputs, - outputs = outputs, - coin = coin, - ) - }.onFailure { - Logger.error("Trezor convertToSignParams failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + trezorService.precomposedToSignParams( + inputs = inputs, + outputs = outputs, + coin = coin, + ) + }.onFailure { + Logger.error("Trezor convertToSignParams failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } } suspend fun fetchPrevTxs( txids: List, network: TrezorCoinType, - ): Result> = runCatching { - trezorService.fetchPrevTxs( - txids = txids, - electrumUrl = electrumUrlForNetwork(network), - ) - }.onFailure { - Logger.error("Trezor fetchPrevTxs failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } + ): Result> = withContext(bgDispatcher) { + runCatching { + trezorService.fetchPrevTxs( + txids = txids, + electrumUrl = electrumUrlForNetwork(network), + ) + }.onFailure { + Logger.error("Trezor fetchPrevTxs failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } } suspend fun broadcastRawTx( serializedTx: String, network: TrezorCoinType, - ): Result = runCatching { - trezorService.broadcastRawTx( - serializedTx = serializedTx, - electrumUrl = electrumUrlForNetwork(network), - ) - }.onFailure { - Logger.error("Trezor broadcastRawTx failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + trezorService.broadcastRawTx( + serializedTx = serializedTx, + electrumUrl = electrumUrlForNetwork(network), + ) + }.onFailure { + Logger.error("Trezor broadcastRawTx failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } } - suspend fun signTxWithParams(params: TrezorSignTxParams): Result = runCatching { - ensureConnected() - val response = trezorService.signTxWithParams(params) - _state.update { it.copy(error = null) } - response - }.onFailure { - Logger.error("Trezor signTxWithParams failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } + suspend fun signTxWithParams(params: TrezorSignTxParams): Result = withContext(bgDispatcher) { + runCatching { + ensureConnected() + val response = trezorService.signTxWithParams(params) + _state.update { it.copy(error = null) } + response + }.onFailure { + Logger.error("Trezor signTxWithParams failed", it, context = TAG) + _state.update { s -> s.copy(error = it.message) } + } } fun coinStringForNetwork(network: TrezorCoinType): String = when (network) { TrezorCoinType.BITCOIN -> "Bitcoin" TrezorCoinType.TESTNET -> "Testnet" TrezorCoinType.REGTEST -> "Regtest" - TrezorCoinType.SIGNET -> "Testnet" + TrezorCoinType.SIGNET -> "Signet" } - suspend fun disconnect(): Result = runCatching { - TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") - runCatching { trezorService.disconnect() } - _state.update { - it.copy(connectedDevice = null, connectedDeviceId = null, lastAddress = null, lastPublicKey = null) + suspend fun disconnect(): Result = withContext(bgDispatcher) { + runCatching { + TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") + runCatching { trezorService.disconnect() } + _state.update { + it.copy(connectedDevice = null, connectedDeviceId = null, lastAddress = null, lastPublicKey = null) + } + TrezorDebugLog.log("DISCONNECT", "disconnect() complete (credentials NOT cleared)") + }.onFailure { e -> + TrezorDebugLog.log("DISCONNECT", "FAILED: ${e.message}") + Logger.error("Trezor disconnect failed", e, context = TAG) + _state.update { it.copy(error = e.message) } } - TrezorDebugLog.log("DISCONNECT", "disconnect() complete (credentials NOT cleared)") - }.onFailure { e -> - TrezorDebugLog.log("DISCONNECT", "FAILED: ${e.message}") - Logger.error("Trezor disconnect failed", e, context = TAG) - _state.update { it.copy(error = e.message) } } suspend fun signMessage( path: String = "m/84'/0'/0'/0/0", message: String, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = runCatching { - ensureConnected() - val response = trezorService.signMessage( - path = path, - message = message, - coin = coin, - ) - _state.update { it.copy(error = null) } - response - }.onFailure { e -> - Logger.error("Trezor signMessage failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + ensureConnected() + val response = trezorService.signMessage( + path = path, + message = message, + coin = coin, + ) + _state.update { it.copy(error = null) } + response + }.onFailure { e -> + Logger.error("Trezor signMessage failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } suspend fun verifyMessage( @@ -322,31 +356,33 @@ class TrezorRepo @Inject constructor( signature: String, message: String, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = runCatching { - ensureConnected() - val result = trezorService.verifyMessage( - address = address, - signature = signature, - message = message, - coin = coin, - ) - _state.update { it.copy(error = null) } - result - }.onFailure { e -> - Logger.error("Trezor verifyMessage failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + ensureConnected() + val result = trezorService.verifyMessage( + address = address, + signature = signature, + message = message, + coin = coin, + ) + _state.update { it.copy(error = null) } + result + }.onFailure { e -> + Logger.error("Trezor verifyMessage failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty() - suspend fun autoReconnect(walletIndex: Int = 0): Result { + suspend fun autoReconnect(walletIndex: Int = 0): Result = withContext(bgDispatcher) { val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() } if (knownDevices.isEmpty()) { - return Result.failure(IllegalStateException("No known devices")) + return@withContext Result.failure(IllegalStateException("No known devices")) } _state.update { it.copy(isAutoReconnecting = true, error = null) } - return runCatching { + runCatching { if (!_state.value.isInitialized) { initialize(walletIndex).getOrThrow() } @@ -354,7 +390,10 @@ class TrezorRepo @Inject constructor( _state.value.connectedDevice ?: error("Connected but no features") } else { val scannedDevices = scan().getOrThrow() - val usbDevice = scannedDevices.find { it.transportType == TrezorTransportType.USB } + val knownIds = knownDevices.map { it.id }.toSet() + val usbDevice = scannedDevices.find { + it.transportType == TrezorTransportType.USB && it.id in knownIds + } val idMatch = knownDevices.firstNotNullOfOrNull { known -> scannedDevices.find { it.id == known.id } } @@ -369,11 +408,11 @@ class TrezorRepo @Inject constructor( } } - suspend fun connectKnownDevice(deviceId: String): Result { + suspend fun connectKnownDevice(deviceId: String): Result = withContext(bgDispatcher) { if (_state.value.isConnecting) { - return Result.failure(IllegalStateException("Connection already in progress")) + return@withContext Result.failure(IllegalStateException("Connection already in progress")) } - return runCatching { + runCatching { _state.update { it.copy(isConnecting = true, error = null) } TrezorDebugLog.log("RECONNECT", "=== connectKnownDevice START ===") TrezorDebugLog.log("RECONNECT", "deviceId=$deviceId") @@ -414,24 +453,26 @@ class TrezorRepo @Inject constructor( } } - suspend fun forgetDevice(deviceId: String): Result = runCatching { - TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") - if (_state.value.connectedDeviceId == deviceId) { - runCatching { trezorService.disconnect() } - _state.update { it.copy(connectedDevice = null, connectedDeviceId = null) } + suspend fun forgetDevice(deviceId: String): Result = withContext(bgDispatcher) { + runCatching { + TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") + if (_state.value.connectedDeviceId == deviceId) { + runCatching { trezorService.disconnect() } + _state.update { it.copy(connectedDevice = null, connectedDeviceId = null) } + } + TrezorDebugLog.log("FORGET", "Clearing credentials...") + trezorTransport.clearDeviceCredential(deviceId) + runCatching { trezorService.clearCredentials(deviceId) } + val updated = _state.value.knownDevices.filter { it.id != deviceId } + saveKnownDevices(updated) + _state.update { it.copy(knownDevices = updated) } + TrezorDebugLog.log("FORGET", "Device forgotten successfully") + Logger.info("Forgot device: '$deviceId'", context = TAG) + }.onFailure { e -> + TrezorDebugLog.log("FORGET", "FAILED: ${e.message}") + Logger.error("Forget device failed", e, context = TAG) + _state.update { it.copy(error = e.message) } } - TrezorDebugLog.log("FORGET", "Clearing credentials...") - trezorTransport.clearDeviceCredential(deviceId) - runCatching { trezorService.clearCredentials(deviceId) } - val updated = _state.value.knownDevices.filter { it.id != deviceId } - saveKnownDevices(updated) - _state.update { it.copy(knownDevices = updated) } - TrezorDebugLog.log("FORGET", "Device forgotten successfully") - Logger.info("Forgot device: $deviceId", context = TAG) - }.onFailure { e -> - TrezorDebugLog.log("FORGET", "FAILED: ${e.message}") - Logger.error("Forget device failed", e, context = TAG) - _state.update { it.copy(error = e.message) } } fun clearError() { @@ -443,7 +484,7 @@ class TrezorRepo @Inject constructor( val currentId = _state.value.connectedDeviceId ?: return@onEach val knownDevice = _state.value.knownDevices.find { it.path == path } if (knownDevice?.id == currentId || path.contains(currentId)) { - Logger.warn("External disconnect detected for $currentId", context = TAG) + Logger.warn("External disconnect detected for '$currentId'", context = TAG) _state.update { it.copy(connectedDevice = null, connectedDeviceId = null, error = "Device disconnected") } @@ -516,28 +557,32 @@ class TrezorRepo @Inject constructor( coin: TrezorCoinType = TrezorCoinType.BITCOIN, lockTime: UInt? = null, version: UInt? = null, - ): Result = runCatching { - ensureConnected() - val response = trezorService.signTx( - inputs = inputs, - outputs = outputs, - coin = coin, - lockTime = lockTime, - version = version, - ) - _state.update { it.copy(error = null) } - response - }.onFailure { e -> - Logger.error("Trezor signTx failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + ): Result = withContext(bgDispatcher) { + runCatching { + ensureConnected() + val response = trezorService.signTx( + inputs = inputs, + outputs = outputs, + coin = coin, + lockTime = lockTime, + version = version, + ) + _state.update { it.copy(error = null) } + response + }.onFailure { e -> + Logger.error("Trezor signTx failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } - suspend fun clearCredentials(deviceId: String): Result = runCatching { - trezorService.clearCredentials(deviceId) - _state.update { it.copy(error = null) } - }.onFailure { e -> - Logger.error("Trezor clearCredentials failed", e, context = TAG) - _state.update { it.copy(error = e.message) } + suspend fun clearCredentials(deviceId: String): Result = withContext(bgDispatcher) { + runCatching { + trezorService.clearCredentials(deviceId) + _state.update { it.copy(error = null) } + }.onFailure { e -> + Logger.error("Trezor clearCredentials failed", e, context = TAG) + _state.update { it.copy(error = e.message) } + } } @Suppress("TooGenericExceptionCaught") @@ -557,7 +602,7 @@ class TrezorRepo @Inject constructor( throw e } TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") - Logger.warn("Connection failed for $deviceId, retrying", e, context = TAG) + Logger.warn("Connection failed for '$deviceId', retrying", e, context = TAG) logCredentialFileState(deviceId, "BEFORE 2nd attempt") val result = trezorService.connect(deviceId) logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index ec0ea67fc..124eb1d63 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -24,6 +24,8 @@ import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager +import android.os.Handler +import android.os.Looper import android.os.ParcelUuid import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult @@ -34,6 +36,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import to.bitkit.ext.bluetoothManager import to.bitkit.ext.usbManager import to.bitkit.utils.Logger @@ -138,7 +141,7 @@ class TrezorTransport @Inject constructor( } if (migrated > 0) { espPrefs.edit().clear().commit() - Logger.info("Migrated $migrated THP credentials from SharedPreferences to files", context = TAG) + Logger.info("Migrated '$migrated' THP credentials from SharedPreferences to files", context = TAG) } } catch (e: Exception) { Logger.warn("ESP migration failed (may be inaccessible)", e, context = TAG) @@ -196,7 +199,7 @@ class TrezorTransport @Inject constructor( ) } devices.addAll(usbDevices) - Logger.debug("USB enumerate found ${usbDevices.size} Trezor device(s)", context = TAG) + Logger.debug("USB enumerate found '${usbDevices.size}' Trezor device(s)", context = TAG) } catch (e: Exception) { Logger.error("USB enumerate failed", e, context = TAG) } @@ -205,12 +208,12 @@ class TrezorTransport @Inject constructor( try { val bleDevices = enumerateBleDevices() devices.addAll(bleDevices) - Logger.debug("BLE enumerate found ${bleDevices.size} Trezor device(s)", context = TAG) + Logger.debug("BLE enumerate found '${bleDevices.size}' Trezor device(s)", context = TAG) } catch (e: Exception) { Logger.error("BLE enumerate failed", e, context = TAG) } - Logger.info("Total enumerate found ${devices.size} Trezor device(s)", context = TAG) + Logger.info("Total enumerate found '${devices.size}' Trezor device(s)", context = TAG) val summary = devices.map { "${it.path} (${it.transportType})" } TrezorDebugLog.log("ENUM", "Found ${devices.size} devices: $summary") return devices @@ -266,7 +269,7 @@ class TrezorTransport @Inject constructor( // For BLE/THP devices, the Rust side now handles THP protocol directly. // This callback returns null to let Rust use its built-in THP implementation. Logger.debug( - "callMessage called for $path, type=$messageType - returning null (Rust handles THP)", + "callMessage called for '$path', type='$messageType' - returning null (Rust handles THP)", context = TAG, ) return null @@ -302,7 +305,7 @@ class TrezorTransport @Inject constructor( } val code = submittedPairingCode - Logger.info("Pairing code received (len=${code.length})", context = TAG) + Logger.info("Pairing code received (len='${code.length}')", context = TAG) return code } catch (e: InterruptedException) { Logger.error("Pairing code wait interrupted", e, context = TAG) @@ -333,7 +336,7 @@ class TrezorTransport @Inject constructor( * UI should show a dialog when this is true. */ private val _needsPairingCode = MutableStateFlow(false) - val needsPairingCode: kotlinx.coroutines.flow.StateFlow = _needsPairingCode + val needsPairingCode: StateFlow = _needsPairingCode /** * Submit a pairing code from the UI. @@ -341,7 +344,7 @@ class TrezorTransport @Inject constructor( */ fun submitPairingCode(code: String) { synchronized(pairingCodeLock) { - Logger.info("Pairing code submitted (len=${code.length})", context = TAG) + Logger.info("Pairing code submitted (len='${code.length}')", context = TAG) submittedPairingCode = code _needsPairingCode.value = false pairingCodeRequest.latch?.countDown() @@ -368,7 +371,7 @@ class TrezorTransport @Inject constructor( val existed = file.exists() file.delete() TrezorDebugLog.log("SAVE", "CLEARED credential (file existed=$existed)") - Logger.info("Cleared THP credential for device: $deviceId (path=${file.absolutePath})", context = TAG) + Logger.info("Cleared THP credential for device: '$deviceId' (path='${file.absolutePath}')", context = TAG) return true } @@ -386,7 +389,7 @@ class TrezorTransport @Inject constructor( } Logger.info( - "Saving THP credential to: ${file.absolutePath} (${credentialJson.length} chars)", + "Saving THP credential to: '${file.absolutePath}' (${credentialJson.length} chars)", context = TAG, ) true @@ -416,7 +419,7 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("LOAD", "All credential files: $allFiles") Logger.info( - "Loading THP credential from: ${file.absolutePath}, exists=$exists, size=$size", + "Loading THP credential from: '${file.absolutePath}', exists='$exists', size='$size'", context = TAG, ) if (exists) { @@ -426,12 +429,12 @@ class TrezorTransport @Inject constructor( TrezorDebugLog.log("LOAD", "WARNING: File exists but is blank! Returning null.") null } else { - Logger.info("Loaded THP credential for device: $deviceId (${json.length} chars)", context = TAG) + Logger.info("Loaded THP credential for device: '$deviceId' (${json.length} chars)", context = TAG) json } } else { TrezorDebugLog.log("LOAD", "No credential file found -> returning null") - Logger.debug("No stored THP credential for device: $deviceId", context = TAG) + Logger.debug("No stored THP credential for device: '$deviceId'", context = TAG) null } } catch (e: Exception) { @@ -447,7 +450,7 @@ class TrezorTransport @Inject constructor( val file = credentialFile(deviceId) TrezorDebugLog.log("CLEAR", "clearDeviceCredential for: $deviceId, exists=${file.exists()}") file.delete() - Logger.info("Cleared device credential for: $deviceId", context = TAG) + Logger.info("Cleared device credential for: '$deviceId'", context = TAG) } catch (e: Exception) { TrezorDebugLog.log("CLEAR", "EXCEPTION: ${e.message}") Logger.error("Failed to clear device credential", e, context = TAG) @@ -496,7 +499,7 @@ class TrezorTransport @Inject constructor( ) try { - Logger.info("Requesting USB permission for ${device.deviceName}", context = TAG) + Logger.info("Requesting USB permission for '${device.deviceName}'", context = TAG) usbManager.requestPermission(device, permissionIntent) // Block until user responds (up to 60 seconds) @@ -507,7 +510,7 @@ class TrezorTransport @Inject constructor( } val status = if (granted) "granted" else "denied" - Logger.info("USB permission $status for ${device.deviceName}", context = TAG) + Logger.info("USB permission '$status' for '${device.deviceName}'", context = TAG) return granted } finally { try { context.unregisterReceiver(receiver) } catch (_: Exception) {} @@ -552,7 +555,7 @@ class TrezorTransport @Inject constructor( if (!requestUsbPermission(device)) { return TrezorTransportWriteResult( success = false, - error = "USB permission denied for $path", + error = "USB permission denied for '$path'", ) } } @@ -582,7 +585,7 @@ class TrezorTransport @Inject constructor( endpoints.read, endpoints.write, ) - Logger.info("USB device opened: $path", context = TAG) + Logger.info("USB device opened: '$path'", context = TAG) TrezorTransportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB open failed", e, context = TAG) @@ -598,7 +601,7 @@ class TrezorTransport @Inject constructor( openDevice.connection.releaseInterface(openDevice.usbInterface) openDevice.connection.close() - Logger.info("USB device closed: $path", context = TAG) + Logger.info("USB device closed: '$path'", context = TAG) TrezorTransportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB close failed", e, context = TAG) @@ -633,7 +636,7 @@ class TrezorTransport @Inject constructor( } val data = buffer.copyOf(bytesRead) - Logger.debug("USB read $bytesRead bytes from $path", context = TAG) + Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) TrezorTransportReadResult(success = true, data = data, error = "") } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) @@ -658,7 +661,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = false, error = "Write failed: $bytesWritten") } - Logger.debug("USB wrote $bytesWritten bytes to $path", context = TAG) + Logger.debug("USB wrote '$bytesWritten' bytes to '$path'", context = TAG) TrezorTransportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB write failed", e, context = TAG) @@ -714,12 +717,12 @@ class TrezorTransport @Inject constructor( val address = device.address if (!discoveredBleDevices.containsKey(address)) { discoveredBleDevices[address] = device - Logger.debug("BLE device found: $address (${device.name})", context = TAG) + Logger.debug("BLE device found: '$address' ('${device.name}')", context = TAG) } } override fun onScanFailed(errorCode: Int) { - Logger.error("BLE scan failed: $errorCode", context = TAG) + Logger.error("BLE scan failed: '$errorCode'", context = TAG) } } @@ -729,7 +732,7 @@ class TrezorTransport @Inject constructor( address: String, ): TrezorTransportWriteResult? { if (device.bondState == BluetoothDevice.BOND_NONE) { - Logger.info("Device not bonded, initiating bonding: $address", context = TAG) + Logger.info("Device not bonded, initiating bonding: '$address'", context = TAG) if (!device.createBond()) { return TrezorTransportWriteResult(success = false, error = "Failed to initiate bonding") } @@ -744,9 +747,9 @@ class TrezorTransport @Inject constructor( if (device.bondState != BluetoothDevice.BOND_BONDED) { return TrezorTransportWriteResult(success = false, error = "Bonding timeout") } - Logger.info("Device bonded successfully: $address", context = TAG) + Logger.info("Device bonded successfully: '$address'", context = TAG) } else if (device.bondState == BluetoothDevice.BOND_BONDING) { - Logger.info("Device is currently bonding, waiting: $address", context = TAG) + Logger.info("Device is currently bonding, waiting: '$address'", context = TAG) var bondAttempts = 0 while (device.bondState == BluetoothDevice.BOND_BONDING && bondAttempts < MAX_BOND_POLL_ATTEMPTS) { Thread.sleep(BOND_POLL_INTERVAL_MS) @@ -756,7 +759,7 @@ class TrezorTransport @Inject constructor( return TrezorTransportWriteResult(success = false, error = "Bonding failed") } } else { - Logger.info("Device already bonded: $address", context = TAG) + Logger.info("Device already bonded: '$address'", context = TAG) } return null } @@ -810,14 +813,14 @@ class TrezorTransport @Inject constructor( // Stabilization delay: device THP layer needs time after BLE reconnect Thread.sleep(BLE_CONNECTION_STABILIZATION_MS) - Logger.info("BLE device opened: $path", context = TAG) + Logger.info("BLE device opened: '$path'", context = TAG) return TrezorTransportWriteResult(success = true, error = "") } @Suppress("TooGenericExceptionCaught") @SuppressLint("MissingPermission") private fun closeBleDevice(path: String): TrezorTransportWriteResult { - val connection = bleConnections.remove(path) + val connection = bleConnections[path] ?: return TrezorTransportWriteResult(success = true, error = "") userInitiatedCloseSet.add(path) @@ -829,7 +832,7 @@ class TrezorTransport @Inject constructor( val disconnected = disconnectLatch.await(DISCONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS) if (!disconnected) { - Logger.warn("BLE disconnect timeout, forcing close: $path", context = TAG) + Logger.warn("BLE disconnect timeout, forcing close: '$path'", context = TAG) } bleConnections.remove(path) @@ -839,7 +842,7 @@ class TrezorTransport @Inject constructor( Logger.error("BLE close failed", e, context = TAG) } - Logger.info("BLE device closed: $path", context = TAG) + Logger.info("BLE device closed: '$path'", context = TAG) return TrezorTransportWriteResult(success = true, error = "") } @@ -860,7 +863,7 @@ class TrezorTransport @Inject constructor( error = "Read timeout" ) - Logger.debug("BLE read ${data.size} bytes from $path", context = TAG) + Logger.debug("BLE read ${data.size} bytes from '$path'", context = TAG) TrezorTransportReadResult(success = true, data = data, error = "") } catch (e: Exception) { Logger.error("BLE read failed", e, context = TAG) @@ -885,7 +888,7 @@ class TrezorTransport @Inject constructor( ?: return TrezorTransportWriteResult(success = false, error = "Write characteristic not available") if (!connection.isConnected) { - Logger.warn("BLE write attempted on disconnected device: $path", context = TAG) + Logger.warn("BLE write attempted on disconnected device: '$path'", context = TAG) return TrezorTransportWriteResult(success = false, error = "Device disconnected") } @@ -907,8 +910,8 @@ class TrezorTransport @Inject constructor( val connState = connection.isConnected val charPropsHex = Integer.toHexString(writeChar.properties) Logger.warn( - "BLE write initiation failed (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path, " + - "isConnected=$connState, charProps=0x$charPropsHex, dataLen=${data.size}", + "BLE write initiation failed (attempt '$attempt'/'$BLE_WRITE_RETRY_COUNT'): '$path', " + + "isConnected='$connState', charProps='0x$charPropsHex', dataLen='${data.size}'", context = TAG, ) if (attempt < BLE_WRITE_RETRY_COUNT) { @@ -920,7 +923,7 @@ class TrezorTransport @Inject constructor( if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { lastError = "Write timeout" - Logger.warn("BLE write timeout (attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", context = TAG) + Logger.warn("BLE write timeout (attempt '$attempt'/'$BLE_WRITE_RETRY_COUNT'): '$path'", context = TAG) if (attempt < BLE_WRITE_RETRY_COUNT) { Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue @@ -931,8 +934,8 @@ class TrezorTransport @Inject constructor( if (connection.writeStatus != BluetoothGatt.GATT_SUCCESS) { lastError = "Write callback failed: ${connection.writeStatus}" Logger.warn( - "BLE write callback failed with status ${connection.writeStatus} " + - "(attempt $attempt/$BLE_WRITE_RETRY_COUNT): $path", + "BLE write callback failed with status '${connection.writeStatus}' " + + "(attempt '$attempt'/'$BLE_WRITE_RETRY_COUNT'): '$path'", context = TAG, ) if (attempt < BLE_WRITE_RETRY_COUNT) { @@ -943,7 +946,7 @@ class TrezorTransport @Inject constructor( } // Success! - Logger.debug("BLE wrote ${data.size} bytes to $path (attempt $attempt)", context = TAG) + Logger.debug("BLE wrote '${data.size}' bytes to '$path' (attempt '$attempt')", context = TAG) // Small delay between writes to avoid overwhelming the GATT Thread.sleep(BLE_WRITE_INTER_DELAY_MS) @@ -966,15 +969,15 @@ class TrezorTransport @Inject constructor( when (newState) { BluetoothProfile.STATE_CONNECTED -> { - Logger.debug("BLE connected, requesting MTU: $path", context = TAG) + Logger.debug("BLE connected, requesting MTU: '$path'", context = TAG) val mtuResult = gatt.requestMtu(256) if (!mtuResult) { - Logger.warn("MTU request failed, proceeding with service discovery: $path", context = TAG) + Logger.warn("MTU request failed, proceeding with service discovery: '$path'", context = TAG) gatt.discoverServices() } } BluetoothProfile.STATE_DISCONNECTED -> { - Logger.debug("BLE disconnected: $path", context = TAG) + Logger.debug("BLE disconnected: '$path'", context = TAG) connection?.isConnected = false connection?.connectionLatch?.countDown() connection?.disconnectLatch?.countDown() @@ -988,9 +991,9 @@ class TrezorTransport @Inject constructor( override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) { val path = "ble:${gatt.device.address}" if (status == BluetoothGatt.GATT_SUCCESS) { - Logger.info("MTU changed to $mtu for $path", context = TAG) + Logger.info("MTU changed to '$mtu' for '$path'", context = TAG) } else { - Logger.warn("MTU change failed with status $status for $path", context = TAG) + Logger.warn("MTU change failed with status '$status' for '$path'", context = TAG) } gatt.discoverServices() } @@ -1000,7 +1003,7 @@ class TrezorTransport @Inject constructor( val connection = bleConnections[path] ?: return if (status != BluetoothGatt.GATT_SUCCESS) { - Logger.error("Service discovery failed: $status", context = TAG) + Logger.error("Service discovery failed: '$status'", context = TAG) connection.connectionLatch?.countDown() return } @@ -1046,20 +1049,20 @@ class TrezorTransport @Inject constructor( @Suppress("DEPRECATION") val writeResult = gatt.writeDescriptor(descriptor) if (!writeResult) { - Logger.warn("CCCD descriptor write failed to initiate: $path", context = TAG) + Logger.warn("CCCD descriptor write failed to initiate: '$path'", context = TAG) // Also enable CCCD for PUSH characteristic before signaling ready enablePushCccd(gatt, pushChar, path) connection.isConnected = true connection.connectionLatch?.countDown() } } else { - Logger.warn("CCCD descriptor not found, proceeding: $path", context = TAG) + Logger.warn("CCCD descriptor not found, proceeding: '$path'", context = TAG) enablePushCccd(gatt, pushChar, path) connection.isConnected = true connection.connectionLatch?.countDown() } - Logger.info("BLE services discovered: $path", context = TAG) + Logger.info("BLE services discovered: '$path'", context = TAG) } override fun onCharacteristicChanged( @@ -1071,7 +1074,7 @@ class TrezorTransport @Inject constructor( // Only process notifications from the NOTIFY characteristic if (characteristic.uuid != NOTIFY_CHAR_UUID) { - Logger.debug("Ignoring notification from non-TX char: ${characteristic.uuid}", context = TAG) + Logger.debug("Ignoring notification from non-TX char: '${characteristic.uuid}'", context = TAG) return } @@ -1080,7 +1083,7 @@ class TrezorTransport @Inject constructor( if (data != null && data.isNotEmpty()) { connection.readQueue.offer(data) - Logger.debug("BLE TX notification: ${data.size} bytes", context = TAG) + Logger.debug("BLE TX notification: '${data.size}' bytes", context = TAG) } } @@ -1093,7 +1096,7 @@ class TrezorTransport @Inject constructor( val connection = bleConnections[path] ?: return connection.writeStatus = status if (status != BluetoothGatt.GATT_SUCCESS) { - Logger.warn("BLE write callback status: $status for $path", context = TAG) + Logger.warn("BLE write callback status: '$status' for '$path'", context = TAG) } connection.writeLatch?.countDown() } @@ -1109,18 +1112,18 @@ class TrezorTransport @Inject constructor( val charUuid = descriptor.characteristic.uuid if (status == BluetoothGatt.GATT_SUCCESS) { Logger.info( - "CCCD descriptor write complete for $charUuid: $path", + "CCCD descriptor write complete for '$charUuid': '$path'", context = TAG, ) } else { Logger.warn( - "CCCD descriptor write failed with status $status for $charUuid: $path", + "CCCD descriptor write failed with status '$status' for '$charUuid': '$path'", context = TAG, ) } // Delay subsequent GATT operations without blocking the callback thread - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + Handler(Looper.getMainLooper()).postDelayed({ // If this was the TX characteristic CCCD, also enable PUSH CCCD if (descriptor.characteristic.uuid == NOTIFY_CHAR_UUID) { val pushChar = gatt.getService(SERVICE_UUID)?.getCharacteristic(PUSH_CHAR_UUID) @@ -1152,7 +1155,7 @@ class TrezorTransport @Inject constructor( @Suppress("DEPRECATION") val result = gatt.writeDescriptor(pushDescriptor) if (!result) { - Logger.warn("PUSH CCCD descriptor write failed to initiate: $path", context = TAG) + Logger.warn("PUSH CCCD descriptor write failed to initiate: '$path'", context = TAG) } return result } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 82727aa93..959b5976c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -122,7 +121,7 @@ private fun TrezorScreenContent( ScreenColumn { AppTopBar( - titleText = stringResource(R.string.settings__adv__trezor), + titleText = "Trezor", onBackClick = onBack, actions = { DrawerNavIcon() }, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index 89e678557..bb97b15b9 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -159,10 +159,10 @@ private fun Content( // Hardware Wallet Section if (isDevModeEnabled) { - SectionHeader(title = stringResource(R.string.settings__adv__section_hardware_wallet)) + SectionHeader(title = "Hardware Wallet") SettingsButtonRow( - title = stringResource(R.string.settings__adv__trezor), + title = "Trezor", onClick = onTrezorClick, modifier = Modifier.testTag("Trezor"), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0ffebc06..7115a659a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -572,12 +572,10 @@ Are you sure you want to reset the suggestions? They will reappear in case you have removed them from your Bitkit wallet overview. Reset Suggestions? Rapid-Gossip-Sync - Hardware Wallet Networks Other Payments Reset Suggestions - Trezor Advanced Connection Receipts Connections From 4ad6c84f0b8088661ef200a442986edc25bff238 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Mon, 9 Mar 2026 12:34:59 -0400 Subject: [PATCH 18/48] fix: address pr #792 review feedback --- .../main/java/to/bitkit/services/TrezorTransport.kt | 10 ++++++++-- .../java/to/bitkit/ui/screens/trezor/TrezorScreen.kt | 2 -- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 124eb1d63..6731ccc79 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -371,7 +371,10 @@ class TrezorTransport @Inject constructor( val existed = file.exists() file.delete() TrezorDebugLog.log("SAVE", "CLEARED credential (file existed=$existed)") - Logger.info("Cleared THP credential for device: '$deviceId' (path='${file.absolutePath}')", context = TAG) + Logger.info( + "Cleared THP credential for device: '$deviceId' (path='${file.absolutePath}')", + context = TAG, + ) return true } @@ -923,7 +926,10 @@ class TrezorTransport @Inject constructor( if (!writeLatch.await(WRITE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)) { lastError = "Write timeout" - Logger.warn("BLE write timeout (attempt '$attempt'/'$BLE_WRITE_RETRY_COUNT'): '$path'", context = TAG) + Logger.warn( + "BLE write timeout (attempt '$attempt'/'$BLE_WRITE_RETRY_COUNT'): '$path'", + context = TAG, + ) if (attempt < BLE_WRITE_RETRY_COUNT) { Thread.sleep(BLE_WRITE_RETRY_DELAY_MS) continue diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index e4ac95b53..4012271fd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -37,8 +37,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel From 82d27d8f9ee6a9464881abcf7c64501ed491f81a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Mar 2026 09:23:12 -0300 Subject: [PATCH 19/48] chore: add preview data and previews for trezor screens --- .../ui/screens/trezor/AddressSection.kt | 41 +++ .../ui/screens/trezor/BalanceLookupSection.kt | 94 +++++++ .../screens/trezor/ConnectedDeviceSection.kt | 18 ++ .../ui/screens/trezor/DeviceListSection.kt | 44 +++ .../ui/screens/trezor/PairingCodeDialog.kt | 10 + .../ui/screens/trezor/PublicKeySection.kt | 38 +++ .../screens/trezor/SendTransactionSection.kt | 111 ++++++++ .../ui/screens/trezor/SignMessageSection.kt | 41 +++ .../ui/screens/trezor/TrezorPreviewData.kt | 258 ++++++++++++++++++ .../bitkit/ui/screens/trezor/TrezorScreen.kt | 42 +-- 10 files changed, 669 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index 60a3b5b1b..5a42591ef 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.repositories.TrezorState @@ -26,6 +27,7 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -115,3 +117,42 @@ internal fun AddressSection( } } } + +@Preview +@Composable +private fun PreviewAddressSectionEmpty() { + AppThemeSurface { + AddressSection( + trezorState = TrezorPreviewData.connectedState, + uiState = TrezorUiState(), + onGetAddress = {}, + onIncrementIndex = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewAddressSectionWithAddress() { + AppThemeSurface { + AddressSection( + trezorState = TrezorPreviewData.connectedStateWithResults, + uiState = TrezorUiState(), + onGetAddress = {}, + onIncrementIndex = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewAddressSectionLoading() { + AppThemeSurface { + AddressSection( + trezorState = TrezorPreviewData.connectedState, + uiState = TrezorUiState(isGettingAddress = true), + onGetAddress = {}, + onIncrementIndex = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index fb8d95dfa..dcbd372c2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountUtxo @@ -29,6 +30,7 @@ import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -254,3 +256,95 @@ internal fun ResultCard(content: @Composable () -> Unit) { content() } } + +@Preview +@Composable +private fun PreviewBalanceLookupEmpty() { + AppThemeSurface { + BalanceLookupSection( + uiState = TrezorUiState(), + isDeviceConnected = false, + onInputChange = {}, + onLookup = {}, + onSendAddressChange = {}, + onSendAmountChange = {}, + onSendFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBackToForm = {}, + onResetSend = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewBalanceLookupWithAccountInfo() { + AppThemeSurface { + BalanceLookupSection( + uiState = TrezorPreviewData.uiStateWithAccountInfo, + isDeviceConnected = true, + onInputChange = {}, + onLookup = {}, + onSendAddressChange = {}, + onSendAmountChange = {}, + onSendFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBackToForm = {}, + onResetSend = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewBalanceLookupWithAddressInfo() { + AppThemeSurface { + BalanceLookupSection( + uiState = TrezorPreviewData.uiStateWithAddressInfo, + isDeviceConnected = false, + onInputChange = {}, + onLookup = {}, + onSendAddressChange = {}, + onSendAmountChange = {}, + onSendFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBackToForm = {}, + onResetSend = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewBalanceLookupLoading() { + AppThemeSurface { + BalanceLookupSection( + uiState = TrezorUiState(lookupInput = "xpub6C...", isLookingUp = true), + isDeviceConnected = false, + onInputChange = {}, + onLookup = {}, + onSendAddressChange = {}, + onSendAmountChange = {}, + onSendFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBackToForm = {}, + onResetSend = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt index d1f8a4b20..c701b8b69 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/ConnectedDeviceSection.kt @@ -10,9 +10,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorFeatures import to.bitkit.ui.components.Caption +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @Composable @@ -35,6 +37,22 @@ internal fun ConnectedDeviceInfo(features: TrezorFeatures) { } } +@Preview +@Composable +private fun PreviewConnectedDeviceInfo() { + AppThemeSurface { + ConnectedDeviceInfo(TrezorPreviewData.sampleFeatures) + } +} + +@Preview +@Composable +private fun PreviewConnectedDeviceInfoMinimal() { + AppThemeSurface { + ConnectedDeviceInfo(TrezorPreviewData.sampleFeaturesMinimal) + } +} + @Composable internal fun InfoRow(label: String, value: String) { Row( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt index a3183da76..198cc3797 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/DeviceListSection.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorTransportType @@ -25,6 +26,7 @@ import to.bitkit.ui.components.Caption import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @Composable @@ -136,3 +138,45 @@ internal fun KnownDeviceCard( ) } } + +@Preview +@Composable +private fun PreviewDeviceCardBluetooth() { + AppThemeSurface { + DeviceCard(device = TrezorPreviewData.sampleNearbyDevice, onClick = {}) + } +} + +@Preview +@Composable +private fun PreviewDeviceCardUsb() { + AppThemeSurface { + DeviceCard(device = TrezorPreviewData.sampleNearbyDeviceUsb, onClick = {}) + } +} + +@Preview +@Composable +private fun PreviewKnownDeviceCardConnected() { + AppThemeSurface { + KnownDeviceCard( + device = TrezorPreviewData.sampleKnownDevice, + isConnected = true, + onClick = {}, + onForget = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewKnownDeviceCardDisconnected() { + AppThemeSurface { + KnownDeviceCard( + device = TrezorPreviewData.sampleKnownDeviceBle, + isConnected = false, + onClick = {}, + onForget = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt index 0d0681a0b..7882cb4e9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import to.bitkit.ui.components.ButtonSize @@ -22,6 +23,7 @@ import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @Composable @@ -95,3 +97,11 @@ internal fun PairingCodeDialog( }, ) } + +@Preview +@Composable +private fun PreviewPairingCodeDialog() { + AppThemeSurface { + PairingCodeDialog(onSubmit = {}, onCancel = {}) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index cc512ae0b..4d8c82974 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.repositories.TrezorState @@ -27,6 +28,7 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -143,3 +145,39 @@ internal fun PublicKeySection( } } } + +@Preview +@Composable +private fun PreviewPublicKeySectionEmpty() { + AppThemeSurface { + PublicKeySection( + trezorState = TrezorPreviewData.connectedState, + uiState = TrezorUiState(), + onGetPublicKey = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewPublicKeySectionWithKey() { + AppThemeSurface { + PublicKeySection( + trezorState = TrezorPreviewData.connectedStateWithResults, + uiState = TrezorUiState(), + onGetPublicKey = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewPublicKeySectionLoading() { + AppThemeSurface { + PublicKeySection( + trezorState = TrezorPreviewData.connectedState, + uiState = TrezorUiState(isGettingPublicKey = true), + onGetPublicKey = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index aaf8933e6..2c96b31bb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.TrezorPrecomposedOutput import com.synonym.bitkitcore.TrezorPrecomposedResult @@ -33,6 +34,7 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -423,3 +425,112 @@ private fun BroadcastResultCard(txid: String) { ) } } + +@Preview +@Composable +private fun PreviewSendForm() { + AppThemeSurface { + SendTransactionSection( + uiState = TrezorUiState(), + isDeviceConnected = true, + onAddressChange = {}, + onAmountChange = {}, + onFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBack = {}, + onReset = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSendFormFilled() { + AppThemeSurface { + SendTransactionSection( + uiState = TrezorUiState( + sendAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + sendAmountSats = "45000", + sendFeeRate = "5", + ), + isDeviceConnected = true, + onAddressChange = {}, + onAmountChange = {}, + onFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBack = {}, + onReset = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSendReview() { + AppThemeSurface { + SendTransactionSection( + uiState = TrezorPreviewData.uiStateReview, + isDeviceConnected = true, + onAddressChange = {}, + onAmountChange = {}, + onFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBack = {}, + onReset = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSendSigned() { + AppThemeSurface { + SendTransactionSection( + uiState = TrezorPreviewData.uiStateSigned, + isDeviceConnected = true, + onAddressChange = {}, + onAmountChange = {}, + onFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBack = {}, + onReset = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSendBroadcast() { + AppThemeSurface { + SendTransactionSection( + uiState = TrezorPreviewData.uiStateBroadcast, + isDeviceConnected = true, + onAddressChange = {}, + onAmountChange = {}, + onFeeRateChange = {}, + onToggleSendMax = {}, + onSortingStrategyChange = {}, + onCompose = {}, + onSign = {}, + onBroadcast = {}, + onBack = {}, + onReset = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index 20dbfc9a0..fe554ab72 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import to.bitkit.R import to.bitkit.ui.components.ButtonSize @@ -28,6 +29,7 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -124,3 +126,42 @@ internal fun SignMessageSection( } } } + +@Preview +@Composable +private fun PreviewSignMessageSectionDefault() { + AppThemeSurface { + SignMessageSection( + uiState = TrezorUiState(), + onMessageChange = {}, + onSignMessage = {}, + onVerifyMessage = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSignMessageSectionWithSignature() { + AppThemeSurface { + SignMessageSection( + uiState = TrezorPreviewData.uiStateWithSignature, + onMessageChange = {}, + onSignMessage = {}, + onVerifyMessage = {}, + ) + } +} + +@Preview +@Composable +private fun PreviewSignMessageSectionSigning() { + AppThemeSurface { + SignMessageSection( + uiState = TrezorUiState(isSigningMessage = true), + onMessageChange = {}, + onSignMessage = {}, + onVerifyMessage = {}, + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt new file mode 100644 index 000000000..0604a697f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -0,0 +1,258 @@ +package to.bitkit.ui.screens.trezor + +import com.synonym.bitkitcore.AccountAddresses +import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.AccountUtxo +import com.synonym.bitkitcore.ComposeAccount +import com.synonym.bitkitcore.SingleAddressInfoResult +import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorCoinType +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorPrecomposedInput +import com.synonym.bitkitcore.TrezorPrecomposedOutput +import com.synonym.bitkitcore.TrezorPrecomposedResult +import com.synonym.bitkitcore.TrezorPublicKeyResponse +import com.synonym.bitkitcore.TrezorScriptType +import com.synonym.bitkitcore.TrezorSignedTx +import com.synonym.bitkitcore.TrezorTransportType +import to.bitkit.repositories.KnownDevice +import to.bitkit.repositories.TrezorState + +internal object TrezorPreviewData { + + val sampleFeatures = TrezorFeatures( + vendor = "trezor.io", + model = "Safe 5", + label = "My Trezor", + deviceId = "trezor-abc123", + majorVersion = 2u, + minorVersion = 8u, + patchVersion = 1u, + pinProtection = true, + passphraseProtection = false, + initialized = true, + needsBackup = false, + ) + + val sampleFeaturesMinimal = TrezorFeatures( + vendor = null, + model = null, + label = null, + deviceId = null, + majorVersion = null, + minorVersion = null, + patchVersion = null, + pinProtection = null, + passphraseProtection = null, + initialized = null, + needsBackup = null, + ) + + val sampleKnownDevice = KnownDevice( + id = "usb-1", + name = "Trezor Safe 5", + path = "/dev/usb/001", + transportType = "usb", + label = "My Savings", + model = "Safe 5", + lastConnectedAt = 1_700_000_000_000L, + ) + + val sampleKnownDeviceBle = KnownDevice( + id = "ble-1", + name = "Trezor Safe 7", + path = "AA:BB:CC:DD:EE:FF", + transportType = "bluetooth", + label = "Daily Wallet", + model = "Safe 7", + lastConnectedAt = 1_700_000_000_000L, + ) + + val sampleNearbyDevice = TrezorDeviceInfo( + id = "ble-2", + transportType = TrezorTransportType.BLUETOOTH, + name = "Trezor Safe 7", + path = "11:22:33:44:55:66", + label = null, + model = "Safe 7", + isBootloader = false, + ) + + val sampleNearbyDeviceUsb = TrezorDeviceInfo( + id = "usb-2", + transportType = TrezorTransportType.USB, + name = "Trezor Model T", + path = "/dev/usb/002", + label = "Backup Device", + model = "Model T", + isBootloader = false, + ) + + private const val SAMPLE_ADDRESS = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + private const val SAMPLE_TXID = + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + private const val SAMPLE_XPUB = + "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqY" + + "h2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz" + private const val SAMPLE_PUBKEY = + "02a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + private const val SAMPLE_CHAIN_CODE = + "f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2" + + val sampleAddressResponse = TrezorAddressResponse( + address = SAMPLE_ADDRESS, + path = "m/84'/0'/0'/0/0", + ) + + val samplePublicKeyResponse = TrezorPublicKeyResponse( + xpub = SAMPLE_XPUB, + path = "m/84'/0'/0'", + publicKey = SAMPLE_PUBKEY, + chainCode = SAMPLE_CHAIN_CODE, + fingerprint = 0x1234u, + depth = 3u, + rootFingerprint = 0x5678u, + ) + + val sampleAccountUtxo = AccountUtxo( + txid = SAMPLE_TXID, + vout = 0u, + amount = 50_000uL, + blockHeight = 849_990u, + address = SAMPLE_ADDRESS, + path = "m/84'/0'/0'/0/0", + confirmations = 6u, + coinbase = false, + own = true, + required = null, + ) + + private val sampleAccountUtxo2 = AccountUtxo( + txid = "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", + vout = 1u, + amount = 100_000uL, + blockHeight = 849_985u, + address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + path = "m/84'/0'/0'/0/1", + confirmations = 11u, + coinbase = false, + own = true, + required = null, + ) + + val sampleAccountInfoResult = AccountInfoResult( + account = ComposeAccount( + path = "m/84'/0'/0'", + addresses = AccountAddresses( + used = emptyList(), + unused = emptyList(), + change = emptyList(), + ), + utxo = listOf(sampleAccountUtxo, sampleAccountUtxo2), + ), + balance = 150_000uL, + utxoCount = 2u, + accountType = AccountType.NATIVE_SEGWIT, + blockHeight = 850_000u, + ) + + val sampleAddressInfoResult = SingleAddressInfoResult( + address = SAMPLE_ADDRESS, + balance = 50_000uL, + utxos = listOf(sampleAccountUtxo), + transfers = 3u, + blockHeight = 850_000u, + ) + + private val samplePrecomposedInput = TrezorPrecomposedInput( + txid = SAMPLE_TXID, + vout = 0u, + amount = "50000", + address = SAMPLE_ADDRESS, + path = "m/84'/0'/0'/0/0", + scriptType = TrezorScriptType.SPEND_WITNESS, + ) + + val samplePrecomposedResult = TrezorPrecomposedResult.Final( + totalSpent = "51000", + fee = "1000", + feePerByte = "5.0", + bytes = 200u, + inputs = listOf(samplePrecomposedInput), + outputs = listOf( + TrezorPrecomposedOutput.Payment( + address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + amount = "45000", + ), + TrezorPrecomposedOutput.Change( + address = "bc1q9h5yjqka5pv0arpmc2c2fqhx6l2eme72sxlfkn", + path = "m/84'/0'/0'/1/0", + amount = "4000", + scriptType = TrezorScriptType.SPEND_WITNESS, + ), + ), + outputsPermutation = listOf(0u, 1u), + ) + + val sampleSignedTx = TrezorSignedTx( + signatures = listOf("304402200a0b0c..."), + serializedTx = "0200000001a1b2c3d4...deadbeef", + txid = "c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5", + ) + + val connectedState = TrezorState( + isInitialized = true, + connectedDevice = sampleFeatures, + connectedDeviceId = "trezor-abc123", + ) + + val connectedStateWithResults = TrezorState( + isInitialized = true, + connectedDevice = sampleFeatures, + connectedDeviceId = "trezor-abc123", + lastAddress = sampleAddressResponse, + lastPublicKey = samplePublicKeyResponse, + ) + + val uiStateWithSignature = TrezorUiState( + selectedNetwork = TrezorCoinType.REGTEST, + lastSignature = "H3bK9x...signature...base64==", + lastSigningAddress = SAMPLE_ADDRESS, + ) + + val uiStateWithAccountInfo = TrezorUiState( + selectedNetwork = TrezorCoinType.REGTEST, + lookupInput = SAMPLE_XPUB, + accountInfoResult = sampleAccountInfoResult, + ) + + val uiStateWithAddressInfo = TrezorUiState( + selectedNetwork = TrezorCoinType.REGTEST, + lookupInput = SAMPLE_ADDRESS, + addressInfoResult = sampleAddressInfoResult, + ) + + val uiStateReview = TrezorUiState( + selectedNetwork = TrezorCoinType.REGTEST, + sendStep = SendStep.REVIEW, + sendAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + sendAmountSats = "45000", + sendFeeRate = "5", + precomposedResult = samplePrecomposedResult, + ) + + val uiStateSigned = TrezorUiState( + selectedNetwork = TrezorCoinType.REGTEST, + sendStep = SendStep.SIGNED, + signedTxResult = sampleSignedTx, + ) + + val uiStateBroadcast = TrezorUiState( + selectedNetwork = TrezorCoinType.REGTEST, + sendStep = SendStep.SIGNED, + signedTxResult = sampleSignedTx, + broadcastTxid = "c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5", + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 4012271fd..35146415c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -45,9 +45,7 @@ import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.synonym.bitkitcore.TrezorCoinType -import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorSortingStrategy -import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.R import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState @@ -646,38 +644,26 @@ private fun PreviewInitialized() { @Preview @Composable private fun PreviewWithDevices() { - val knownDevices = listOf( - KnownDevice( - id = "usb-1", - transportType = "usb", - name = "Trezor Safe 5", - path = "/dev/usb/001", - label = "My Savings", - model = "Safe 5", - lastConnectedAt = System.currentTimeMillis(), - ), - ) - val nearbyDevices = listOf( - TrezorDeviceInfo( - id = "ble-1", - transportType = TrezorTransportType.BLUETOOTH, - name = "Trezor Safe 7", - path = "AA:BB:CC:DD:EE:FF", - label = null, - model = "Safe 7", - isBootloader = false, - ), - ) - AppThemeSurface { TrezorContent( trezorState = TrezorState( isInitialized = true, - knownDevices = knownDevices, - nearbyDevices = nearbyDevices, - connectedDeviceId = "usb-1", + knownDevices = listOf(TrezorPreviewData.sampleKnownDevice), + nearbyDevices = listOf(TrezorPreviewData.sampleNearbyDevice), + connectedDeviceId = TrezorPreviewData.sampleKnownDevice.id, ), uiState = TrezorUiState(), ) } } + +@Preview +@Composable +private fun PreviewConnectedWithData() { + AppThemeSurface { + TrezorContent( + trezorState = TrezorPreviewData.connectedStateWithResults, + uiState = TrezorPreviewData.uiStateWithSignature, + ) + } +} From d77a92c18e5ced399d5fac34393e40c7489c2b4a Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Mar 2026 09:36:01 -0300 Subject: [PATCH 20/48] fix: replace clickable text with proper components --- .../screens/trezor/SendTransactionSection.kt | 31 ++++++------------- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 13 +++----- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 2c96b31bb..483501d2c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -1,13 +1,10 @@ package to.bitkit.ui.screens.trezor -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField @@ -15,7 +12,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview @@ -28,10 +24,11 @@ import to.bitkit.R import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption import to.bitkit.ui.components.Caption13Up -import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TagButton +import to.bitkit.ui.components.TertiaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface @@ -141,15 +138,11 @@ private fun ComposeForm( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f), ) - val maxColor = if (uiState.isSendMax) Colors.Brand else Colors.White32 - CaptionB( + TertiaryButton( text = "MAX", - color = maxColor, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(maxColor.copy(alpha = 0.15f)) - .clickableAlpha(onClick = onToggleSendMax) - .padding(horizontal = 12.dp, vertical = 12.dp), + onClick = onToggleSendMax, + size = ButtonSize.Small, + fullWidth = false, ) } @@ -203,16 +196,10 @@ private fun SortingStrategyRow( ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { TrezorSortingStrategy.entries.forEach { strategy -> - val isSelected = strategy == selected - val color = if (isSelected) Colors.Brand else Colors.White32 - CaptionB( + TagButton( text = labels[strategy] ?: strategy.name, - color = color, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(color.copy(alpha = 0.15f)) - .clickableAlpha(onClick = { onChange(strategy) }) - .padding(horizontal = 8.dp, vertical = 4.dp), + onClick = { onChange(strategy) }, + isSelected = strategy == selected, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 35146415c..537ae7876 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -58,6 +58,7 @@ import to.bitkit.ui.components.Footnote import to.bitkit.ui.components.HorizontalSpacer import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.TagButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon @@ -406,16 +407,10 @@ private fun NetworkSelectorRow( modifier = Modifier.fillMaxWidth(), ) { TrezorCoinType.entries.filter { it != TrezorCoinType.SIGNET }.forEach { network -> - val isSelected = network == selectedNetwork - val color = if (isSelected) Colors.Brand else Colors.White32 - CaptionB( + TagButton( text = network.name, - color = color, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background(color.copy(alpha = 0.15f)) - .clickableAlpha(onClick = { onNetworkChange(network) }) - .padding(horizontal = 8.dp, vertical = 4.dp), + onClick = { onNetworkChange(network) }, + isSelected = network == selectedNetwork, ) } } From a2a1ae6e8648ff05a7c16cec7dad888b88622630 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Mar 2026 10:02:05 -0300 Subject: [PATCH 21/48] fix: match existent spacing patterns --- .../ui/screens/trezor/AddressSection.kt | 4 ++-- .../ui/screens/trezor/BalanceLookupSection.kt | 8 ++++---- .../ui/screens/trezor/PublicKeySection.kt | 6 +++--- .../screens/trezor/SendTransactionSection.kt | 10 +++++----- .../ui/screens/trezor/SignMessageSection.kt | 4 ++-- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 19 ++++++++++--------- 6 files changed, 26 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt index 5a42591ef..788aa2619 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/AddressSection.kt @@ -50,7 +50,7 @@ internal fun AddressSection( color = Colors.White50, ) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -76,7 +76,7 @@ internal fun AddressSection( trezorState.lastAddress?.let { response -> val onCopyAddress = copyToClipboard(text = response.address, label = "Address") Column { - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Caption( text = "Address:", color = Colors.White50, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index dcbd372c2..613adc367 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -74,7 +74,7 @@ internal fun BalanceLookupSection( modifier = Modifier.fillMaxWidth(), ) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) PrimaryButton( text = if (uiState.isLookingUp) "Looking up..." else "Lookup", @@ -128,7 +128,7 @@ private fun AccountInfoResultView( onResetSend: () -> Unit, ) { Column { - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) ResultCard { InfoRow("Account Type", result.accountType.name) InfoRow("Balance", "${result.balance} sats") @@ -150,7 +150,7 @@ private fun AccountInfoResultView( } if (result.balance > 0uL) { - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) SendTransactionSection( uiState = uiState, isDeviceConnected = isDeviceConnected, @@ -173,7 +173,7 @@ private fun AccountInfoResultView( private fun AddressInfoResultView(result: SingleAddressInfoResult) { val onCopyAddress = copyToClipboard(text = result.address, label = "Address") Column { - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) ResultCard { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt index 4d8c82974..6d87f5b4b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PublicKeySection.kt @@ -54,7 +54,7 @@ internal fun PublicKeySection( color = Colors.White50, ) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -81,7 +81,7 @@ internal fun PublicKeySection( val onCopyXpub = copyToClipboard(text = response.xpub, label = "xpub") val onCopyPublicKey = copyToClipboard(text = response.publicKey, label = "Public Key") Column { - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Caption( text = "xpub:", color = Colors.White50, @@ -111,7 +111,7 @@ internal fun PublicKeySection( ) } - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Caption( text = "Public Key:", color = Colors.White50, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 483501d2c..2a79a9724 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -158,7 +158,7 @@ private fun ComposeForm( modifier = Modifier.fillMaxWidth(), ) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Caption13Up( text = "Coin Selection", @@ -170,7 +170,7 @@ private fun ComposeForm( onChange = onSortingStrategyChange, ) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) PrimaryButton( text = if (uiState.isComposing) "Composing..." else "Compose Transaction", @@ -256,7 +256,7 @@ private fun ReviewSection( } } - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -359,11 +359,11 @@ private fun SignedResultSection( } } - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) if (broadcastTxid != null) { BroadcastResultCard(txid = broadcastTxid) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) } else { PrimaryButton( text = if (isBroadcasting) "Broadcasting..." else "Broadcast", diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt index fe554ab72..22846e953 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SignMessageSection.kt @@ -62,7 +62,7 @@ internal fun SignMessageSection( singleLine = true, ) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Row( horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -90,7 +90,7 @@ internal fun SignMessageSection( uiState.lastSignature?.let { sig -> val onCopySignature = copyToClipboard(text = sig, label = "Signature") Column { - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Caption( text = "Signature:", color = Colors.White50, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 537ae7876..755eca331 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -192,6 +192,7 @@ private fun TrezorContent( Column( modifier = Modifier .fillMaxWidth() + .padding(horizontal = 16.dp) .imePadding() .verticalScroll(rememberScrollState()) ) { @@ -201,7 +202,7 @@ private fun TrezorContent( selectedNetwork = uiState.selectedNetwork, onNetworkChange = onNetworkChange, ) - VerticalSpacer(12.dp) + VerticalSpacer(16.dp) Card( colors = CardDefaults.cardColors(containerColor = Colors.White08), @@ -232,7 +233,7 @@ private fun TrezorContent( exit = fadeOut() + shrinkVertically(), ) { Column { - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) Caption13Up( text = "My Devices (${trezorState.knownDevices.size})", color = Colors.White64, @@ -262,7 +263,7 @@ private fun TrezorContent( exit = fadeOut() + shrinkVertically(), ) { Column { - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) Caption13Up( text = "New Devices (${trezorState.nearbyDevices.size})", color = Colors.White64, @@ -290,7 +291,7 @@ private fun TrezorContent( ) { trezorState.connectedDevice?.let { features -> Column { - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) Caption13Up( text = "Connected Device", color = Colors.White64, @@ -299,7 +300,7 @@ private fun TrezorContent( ConnectedDeviceInfo(features) - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) AddressSection( trezorState = trezorState, @@ -308,7 +309,7 @@ private fun TrezorContent( onIncrementIndex = onIncrementIndex, ) - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) PublicKeySection( trezorState = trezorState, @@ -316,7 +317,7 @@ private fun TrezorContent( onGetPublicKey = onGetPublicKey, ) - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) SignMessageSection( uiState = uiState, @@ -333,7 +334,7 @@ private fun TrezorContent( trezorState.error?.let { error -> val onCopyError = copyToClipboard(text = error, label = "Trezor Error") Column { - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) Row( modifier = Modifier .fillMaxWidth() @@ -372,7 +373,7 @@ private fun TrezorContent( } // Balance Lookup (always visible, no device needed) - VerticalSpacer(16.dp) + VerticalSpacer(32.dp) BalanceLookupSection( uiState = uiState, isDeviceConnected = trezorState.connectedDevice != null, From 20c00244f51d94df3d35f49faa6cf8517971aad4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Tue, 10 Mar 2026 10:30:56 -0300 Subject: [PATCH 22/48] fix: preview and corner radius --- .../to/bitkit/ui/screens/trezor/PairingCodeDialog.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt index 7882cb4e9..a0ce997af 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -1,9 +1,12 @@ package to.bitkit.ui.screens.trezor +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable @@ -36,6 +39,7 @@ internal fun PairingCodeDialog( AlertDialog( onDismissRequest = onCancel, containerColor = Colors.Gray5, + shape = MaterialTheme.shapes.medium, title = { Title( text = "Enter Pairing Code", @@ -98,10 +102,12 @@ internal fun PairingCodeDialog( ) } -@Preview +@Preview(showSystemUi = true) @Composable private fun PreviewPairingCodeDialog() { AppThemeSurface { - PairingCodeDialog(onSubmit = {}, onCancel = {}) + Box(Modifier.fillMaxSize()) { + PairingCodeDialog(onSubmit = {}, onCancel = {}) + } } } From d1f13c413e2e611ef9ea4b5df1745fcbe32a62f8 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 10 Mar 2026 12:19:07 -0400 Subject: [PATCH 23/48] chore: upgrade bitkit-core bindings - Upgrades to the latest bitkit-core bindings v0.1.45 - Updates the implementation accordingly --- app/src/main/java/to/bitkit/models/Network.kt | 8 +++++ .../java/to/bitkit/repositories/TrezorRepo.kt | 21 +++++++++---- .../java/to/bitkit/services/TrezorService.kt | 17 ++++++----- .../ui/screens/trezor/TrezorPreviewData.kt | 14 ++++----- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 23 +++++++------- .../ui/screens/trezor/TrezorViewModel.kt | 30 +++++++++++-------- gradle/libs.versions.toml | 2 +- 7 files changed, 70 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/to/bitkit/models/Network.kt b/app/src/main/java/to/bitkit/models/Network.kt index f415aee84..bb95280d9 100644 --- a/app/src/main/java/to/bitkit/models/Network.kt +++ b/app/src/main/java/to/bitkit/models/Network.kt @@ -39,3 +39,11 @@ fun NetworkType.toLdkNetwork(): Network = when (this) { NetworkType.SIGNET -> Network.SIGNET NetworkType.REGTEST -> Network.REGTEST } + +fun BitkitCoreNetwork.toTrezorCoinType(): TrezorCoinType = when (this) { + BitkitCoreNetwork.BITCOIN -> TrezorCoinType.BITCOIN + BitkitCoreNetwork.TESTNET -> TrezorCoinType.TESTNET + BitkitCoreNetwork.TESTNET4 -> TrezorCoinType.TESTNET + BitkitCoreNetwork.SIGNET -> TrezorCoinType.SIGNET + BitkitCoreNetwork.REGTEST -> TrezorCoinType.REGTEST +} diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 9685a7668..807ede9c5 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -35,7 +35,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import to.bitkit.di.BgDispatcher import to.bitkit.env.Env -import to.bitkit.models.toTrezorCoinType +import to.bitkit.models.toCoreNetwork import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -43,6 +43,7 @@ import to.bitkit.utils.Logger import java.io.File import javax.inject.Inject import javax.inject.Singleton +import com.synonym.bitkitcore.Network as BitkitCoreNetwork @Suppress("TooManyFunctions") @Singleton @@ -209,7 +210,7 @@ class TrezorRepo @Inject constructor( suspend fun getAccountInfo( extendedKey: String, - network: TrezorCoinType = Env.network.toTrezorCoinType(), + network: BitkitCoreNetwork = Env.network.toCoreNetwork(), ): Result = withContext(bgDispatcher) { runCatching { trezorService.getAccountInfo( @@ -225,7 +226,7 @@ class TrezorRepo @Inject constructor( suspend fun getAddressInfo( address: String, - network: TrezorCoinType = Env.network.toTrezorCoinType(), + network: BitkitCoreNetwork = Env.network.toCoreNetwork(), ): Result = withContext(bgDispatcher) { runCatching { trezorService.getAddressInfo( @@ -284,7 +285,7 @@ class TrezorRepo @Inject constructor( suspend fun broadcastRawTx( serializedTx: String, - network: TrezorCoinType, + network: BitkitCoreNetwork, ): Result = withContext(bgDispatcher) { runCatching { trezorService.broadcastRawTx( @@ -524,11 +525,19 @@ class TrezorRepo @Inject constructor( }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } - private fun keyFormatNetwork(network: TrezorCoinType): TrezorCoinType = when (network) { - TrezorCoinType.REGTEST -> TrezorCoinType.TESTNET + private fun keyFormatNetwork(network: BitkitCoreNetwork): BitkitCoreNetwork = when (network) { + BitkitCoreNetwork.REGTEST -> BitkitCoreNetwork.TESTNET else -> network } + private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = when (network) { + BitkitCoreNetwork.BITCOIN -> "ssl://bitkit.to:9999" + BitkitCoreNetwork.TESTNET -> "ssl://electrum.blockstream.info:60002" + BitkitCoreNetwork.TESTNET4 -> "ssl://electrum.blockstream.info:60002" + BitkitCoreNetwork.REGTEST -> "ssl://electrs.bitkit.stag0.blocktank.to:9999" + BitkitCoreNetwork.SIGNET -> "ssl://electrum.blockstream.info:60002" + } + private fun electrumUrlForNetwork(network: TrezorCoinType): String = when (network) { TrezorCoinType.BITCOIN -> "ssl://bitkit.to:9999" TrezorCoinType.TESTNET -> "ssl://electrum.blockstream.info:60002" diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index 929ffbe87..a6ee1cf6d 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -22,14 +22,14 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTxInput import com.synonym.bitkitcore.TrezorTxOutput import com.synonym.bitkitcore.TrezorVerifyMessageParams -import com.synonym.bitkitcore.trezorBroadcastRawTx +import com.synonym.bitkitcore.onchainBroadcastRawTx +import com.synonym.bitkitcore.onchainGetAccountInfo +import com.synonym.bitkitcore.onchainGetAddressInfo import com.synonym.bitkitcore.trezorClearCredentials import com.synonym.bitkitcore.trezorConnect import com.synonym.bitkitcore.trezorDisconnect import com.synonym.bitkitcore.trezorFetchPrevTxs -import com.synonym.bitkitcore.trezorGetAccountInfo import com.synonym.bitkitcore.trezorGetAddress -import com.synonym.bitkitcore.trezorGetAddressInfo import com.synonym.bitkitcore.trezorGetConnectedDevice import com.synonym.bitkitcore.trezorGetPublicKey import com.synonym.bitkitcore.trezorInitialize @@ -46,6 +46,7 @@ import com.synonym.bitkitcore.trezorVerifyMessage import to.bitkit.async.ServiceQueue import javax.inject.Inject import javax.inject.Singleton +import com.synonym.bitkitcore.Network as BitkitCoreNetwork @Suppress("TooManyFunctions") @Singleton @@ -246,18 +247,18 @@ class TrezorService @Inject constructor( suspend fun broadcastRawTx(serializedTx: String, electrumUrl: String): String { return ServiceQueue.CORE.background { - trezorBroadcastRawTx(serializedTx = serializedTx, electrumUrl = electrumUrl) + onchainBroadcastRawTx(serializedTx = serializedTx, electrumUrl = electrumUrl) } } suspend fun getAccountInfo( extendedKey: String, electrumUrl: String, - network: TrezorCoinType?, + network: BitkitCoreNetwork?, gapLimit: UInt? = 20u, ): AccountInfoResult { return ServiceQueue.CORE.background { - trezorGetAccountInfo( + onchainGetAccountInfo( extendedKey = extendedKey, electrumUrl = electrumUrl, network = network, @@ -269,10 +270,10 @@ class TrezorService @Inject constructor( suspend fun getAddressInfo( address: String, electrumUrl: String, - network: TrezorCoinType?, + network: BitkitCoreNetwork?, ): SingleAddressInfoResult { return ServiceQueue.CORE.background { - trezorGetAddressInfo( + onchainGetAddressInfo( address = address, electrumUrl = electrumUrl, network = network, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 0604a697f..49e981c7b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -7,7 +7,6 @@ import com.synonym.bitkitcore.AccountUtxo import com.synonym.bitkitcore.ComposeAccount import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse -import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorPrecomposedInput @@ -19,6 +18,7 @@ import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState +import com.synonym.bitkitcore.Network as BitkitCoreNetwork internal object TrezorPreviewData { @@ -217,25 +217,25 @@ internal object TrezorPreviewData { ) val uiStateWithSignature = TrezorUiState( - selectedNetwork = TrezorCoinType.REGTEST, + selectedNetwork = BitkitCoreNetwork.REGTEST, lastSignature = "H3bK9x...signature...base64==", lastSigningAddress = SAMPLE_ADDRESS, ) val uiStateWithAccountInfo = TrezorUiState( - selectedNetwork = TrezorCoinType.REGTEST, + selectedNetwork = BitkitCoreNetwork.REGTEST, lookupInput = SAMPLE_XPUB, accountInfoResult = sampleAccountInfoResult, ) val uiStateWithAddressInfo = TrezorUiState( - selectedNetwork = TrezorCoinType.REGTEST, + selectedNetwork = BitkitCoreNetwork.REGTEST, lookupInput = SAMPLE_ADDRESS, addressInfoResult = sampleAddressInfoResult, ) val uiStateReview = TrezorUiState( - selectedNetwork = TrezorCoinType.REGTEST, + selectedNetwork = BitkitCoreNetwork.REGTEST, sendStep = SendStep.REVIEW, sendAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", sendAmountSats = "45000", @@ -244,13 +244,13 @@ internal object TrezorPreviewData { ) val uiStateSigned = TrezorUiState( - selectedNetwork = TrezorCoinType.REGTEST, + selectedNetwork = BitkitCoreNetwork.REGTEST, sendStep = SendStep.SIGNED, signedTxResult = sampleSignedTx, ) val uiStateBroadcast = TrezorUiState( - selectedNetwork = TrezorCoinType.REGTEST, + selectedNetwork = BitkitCoreNetwork.REGTEST, sendStep = SendStep.SIGNED, signedTxResult = sampleSignedTx, broadcastTxid = "c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5e6f7a8b9c4d5", diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 755eca331..74be2e2cd 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -44,7 +44,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.repositories.KnownDevice @@ -67,6 +66,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard +import com.synonym.bitkitcore.Network as BitkitCoreNetwork private val bluetoothPermissions: List get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -176,7 +176,7 @@ private fun TrezorContent( onClearError: () -> Unit = {}, onLookupInputChange: (String) -> Unit = {}, onLookup: () -> Unit = {}, - onNetworkChange: (TrezorCoinType) -> Unit = {}, + onNetworkChange: (BitkitCoreNetwork) -> Unit = {}, onSendAddressChange: (String) -> Unit = {}, onSendAmountChange: (String) -> Unit = {}, onSendFeeRateChange: (String) -> Unit = {}, @@ -400,20 +400,21 @@ private fun TrezorContent( @Composable private fun NetworkSelectorRow( - selectedNetwork: TrezorCoinType, - onNetworkChange: (TrezorCoinType) -> Unit, + selectedNetwork: BitkitCoreNetwork, + onNetworkChange: (BitkitCoreNetwork) -> Unit, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - TrezorCoinType.entries.filter { it != TrezorCoinType.SIGNET }.forEach { network -> - TagButton( - text = network.name, - onClick = { onNetworkChange(network) }, - isSelected = network == selectedNetwork, - ) - } + BitkitCoreNetwork.entries.filter { it != BitkitCoreNetwork.SIGNET && it != BitkitCoreNetwork.TESTNET4 } + .forEach { network -> + TagButton( + text = network.name, + onClick = { onNetworkChange(network) }, + isSelected = network == selectedNetwork, + ) + } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 166725d28..4169a586e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -27,12 +27,14 @@ import kotlinx.coroutines.launch import to.bitkit.di.BgDispatcher import to.bitkit.env.Env import to.bitkit.models.Toast +import to.bitkit.models.toCoreNetwork import to.bitkit.models.toTrezorCoinType import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorRepo import to.bitkit.services.TrezorDebugLog import to.bitkit.ui.shared.toast.ToastEventBus import javax.inject.Inject +import com.synonym.bitkitcore.Network as BitkitCoreNetwork @Suppress("TooManyFunctions") @HiltViewModel @@ -135,7 +137,7 @@ class TrezorViewModel @Inject constructor( path = state.derivationPath, showOnTrezor = showOnTrezor, scriptType = TrezorScriptType.SPEND_WITNESS, - coin = state.selectedNetwork, + coin = state.selectedNetwork.toTrezorCoinType(), ) .onSuccess { _uiState.update { it.copy(isGettingAddress = false) } @@ -156,7 +158,7 @@ class TrezorViewModel @Inject constructor( trezorRepo.getPublicKey( path = accountPath, showOnTrezor = showOnTrezor, - coin = state.selectedNetwork, + coin = state.selectedNetwork.toTrezorCoinType(), ) .onSuccess { _uiState.update { it.copy(isGettingPublicKey = false) } @@ -173,8 +175,8 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(derivationPath = path) } } - fun setSelectedNetwork(network: TrezorCoinType) { - val coinType = if (network == TrezorCoinType.BITCOIN) "0" else "1" + fun setSelectedNetwork(network: BitkitCoreNetwork) { + val coinType = if (network == BitkitCoreNetwork.BITCOIN) "0" else "1" _uiState.update { it.copy( selectedNetwork = network, @@ -187,7 +189,7 @@ class TrezorViewModel @Inject constructor( fun incrementAddressIndex() { _uiState.update { state -> val newIndex = state.addressIndex + 1 - val coinType = if (state.selectedNetwork == TrezorCoinType.BITCOIN) "0" else "1" + val coinType = if (state.selectedNetwork == BitkitCoreNetwork.BITCOIN) "0" else "1" state.copy( addressIndex = newIndex, derivationPath = "m/84'/$coinType'/0'/0/$newIndex", @@ -280,7 +282,11 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(isSigningMessage = true) } val state = _uiState.value - trezorRepo.signMessage(path = state.derivationPath, message = message, coin = state.selectedNetwork) + trezorRepo.signMessage( + path = state.derivationPath, + message = message, + coin = state.selectedNetwork.toTrezorCoinType() + ) .onSuccess { response -> _uiState.update { it.copy( @@ -314,7 +320,7 @@ class TrezorViewModel @Inject constructor( address = address, signature = signature, message = message, - coin = _uiState.value.selectedNetwork, + coin = _uiState.value.selectedNetwork.toTrezorCoinType(), ) .onSuccess { isValid -> _uiState.update { it.copy(isVerifyingMessage = false) } @@ -405,7 +411,7 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(isComposing = true) } - val coinStr = trezorRepo.coinStringForNetwork(state.selectedNetwork) + val coinStr = trezorRepo.coinStringForNetwork(state.selectedNetwork.toTrezorCoinType()) TrezorDebugLog.log("COMPOSE", "=== composeTx START ===") TrezorDebugLog.log("COMPOSE", "address=${state.sendAddress}") TrezorDebugLog.log("COMPOSE", "amount=${state.sendAmountSats}, sendMax=${state.isSendMax}") @@ -539,7 +545,7 @@ class TrezorViewModel @Inject constructor( trezorRepo.convertToSignParams( inputs = result.inputs, outputs = result.outputs, - coin = state.selectedNetwork, + coin = state.selectedNetwork.toTrezorCoinType(), ).onSuccess { logAndSign(it) } .onFailure { TrezorDebugLog.log("SIGN", "convertToSignParams FAILED: ${it.message}") @@ -550,7 +556,7 @@ class TrezorViewModel @Inject constructor( } private suspend fun logAndSign(signParams: TrezorSignTxParams) { - val network = _uiState.value.selectedNetwork + val network = _uiState.value.selectedNetwork.toTrezorCoinType() val txids = signParams.inputs.map { it.prevHash }.distinct() TrezorDebugLog.log( "SIGN", @@ -651,10 +657,10 @@ class TrezorViewModel @Inject constructor( } data class TrezorUiState( - val selectedNetwork: TrezorCoinType = Env.network.toTrezorCoinType(), + val selectedNetwork: BitkitCoreNetwork = Env.network.toCoreNetwork(), val addressIndex: Int = 0, val derivationPath: String = - "m/84'/${if (Env.network.toTrezorCoinType() == TrezorCoinType.BITCOIN) "0" else "1"}'/0'/0/0", + "m/84'/${if (Env.network.toCoreNetwork() == BitkitCoreNetwork.BITCOIN) "0" else "1"}'/0'/0/0", val messageToSign: String = "Hello, Trezor!", val lastSignature: String? = null, val lastSigningAddress: String? = null, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e96184fb1..6b6b418ed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.44" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.45" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From f635d094bd778abc8866f310eae4408a45d63b81 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 10 Mar 2026 12:36:48 -0400 Subject: [PATCH 24/48] refactor: extract derivation path constants --- app/src/main/java/to/bitkit/repositories/TrezorRepo.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 807ede9c5..97a59e519 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -56,6 +56,8 @@ class TrezorRepo @Inject constructor( companion object { private const val TAG = "TrezorRepo" private const val KEY_KNOWN_DEVICES = "known_devices" + private const val DEFAULT_ADDRESS_PATH = "m/84'/0'/0'/0/0" + private const val DEFAULT_ACCOUNT_PATH = "m/84'/0'/0'" } private val prefs by lazy { @@ -167,7 +169,7 @@ class TrezorRepo @Inject constructor( } suspend fun getAddress( - path: String = "m/84'/0'/0'/0/0", + path: String = DEFAULT_ADDRESS_PATH, showOnTrezor: Boolean = false, scriptType: TrezorScriptType? = TrezorScriptType.SPEND_WITNESS, coin: TrezorCoinType = TrezorCoinType.BITCOIN, @@ -189,7 +191,7 @@ class TrezorRepo @Inject constructor( } suspend fun getPublicKey( - path: String = "m/84'/0'/0'", + path: String = DEFAULT_ACCOUNT_PATH, showOnTrezor: Boolean = false, coin: TrezorCoinType = TrezorCoinType.BITCOIN, ): Result = withContext(bgDispatcher) { @@ -333,7 +335,7 @@ class TrezorRepo @Inject constructor( } suspend fun signMessage( - path: String = "m/84'/0'/0'/0/0", + path: String = DEFAULT_ADDRESS_PATH, message: String, coin: TrezorCoinType = TrezorCoinType.BITCOIN, ): Result = withContext(bgDispatcher) { From 1500a1cb39527616b00e178144c325ad5dade61d Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 10 Mar 2026 13:00:37 -0400 Subject: [PATCH 25/48] fix: address pr #792 review feedback --- .../main/java/to/bitkit/repositories/TrezorRepo.kt | 10 +++++++--- .../main/java/to/bitkit/services/TrezorTransport.kt | 11 +++++++---- .../to/bitkit/ui/screens/trezor/PairingCodeDialog.kt | 6 ++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 97a59e519..44fc1b36e 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -39,6 +39,7 @@ import to.bitkit.models.toCoreNetwork import to.bitkit.services.TrezorDebugLog import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport +import to.bitkit.utils.AppError import to.bitkit.utils.Logger import java.io.File import javax.inject.Inject @@ -381,7 +382,7 @@ class TrezorRepo @Inject constructor( suspend fun autoReconnect(walletIndex: Int = 0): Result = withContext(bgDispatcher) { val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() } if (knownDevices.isEmpty()) { - return@withContext Result.failure(IllegalStateException("No known devices")) + return@withContext Result.failure(AppError("No known devices")) } _state.update { it.copy(isAutoReconnecting = true, error = null) } @@ -413,7 +414,7 @@ class TrezorRepo @Inject constructor( suspend fun connectKnownDevice(deviceId: String): Result = withContext(bgDispatcher) { if (_state.value.isConnecting) { - return@withContext Result.failure(IllegalStateException("Connection already in progress")) + return@withContext Result.failure(AppError("Connection already in progress")) } runCatching { _state.update { it.copy(isConnecting = true, error = null) } @@ -432,7 +433,10 @@ class TrezorRepo @Inject constructor( "Scan found ${scannedDevices.size} devices: ${scannedDevices.map { it.id }}", ) val exactMatch = scannedDevices.find { it.id == deviceId } - val usbDevice = scannedDevices.find { it.transportType == TrezorTransportType.USB } + val knownIds = _state.value.knownDevices.map { it.id }.toSet() + val usbDevice = scannedDevices.find { + it.transportType == TrezorTransportType.USB && it.id in knownIds + } val device = if (exactMatch?.transportType == TrezorTransportType.BLUETOOTH && usbDevice != null) { TrezorDebugLog.log("RECONNECT", "Preferring USB over BLE") usbDevice diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 6731ccc79..00c2379d5 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import to.bitkit.ext.bluetoothManager import to.bitkit.ext.usbManager import to.bitkit.utils.Logger @@ -291,7 +292,7 @@ class TrezorTransport @Inject constructor( synchronized(pairingCodeLock) { submittedPairingCode = "" pairingCodeRequest = PairingCodeRequest(isRequested = true, latch = latch) - _needsPairingCode.value = true + _needsPairingCode.update { true } } try { @@ -300,7 +301,7 @@ class TrezorTransport @Inject constructor( if (!received) { Logger.warn("Pairing code entry timed out", context = TAG) - _needsPairingCode.value = false + _needsPairingCode.update { false } return "" } @@ -309,7 +310,7 @@ class TrezorTransport @Inject constructor( return code } catch (e: InterruptedException) { Logger.error("Pairing code wait interrupted", e, context = TAG) - _needsPairingCode.value = false + _needsPairingCode.update { false } return "" } } @@ -346,7 +347,7 @@ class TrezorTransport @Inject constructor( synchronized(pairingCodeLock) { Logger.info("Pairing code submitted (len='${code.length}')", context = TAG) submittedPairingCode = code - _needsPairingCode.value = false + _needsPairingCode.update { false } pairingCodeRequest.latch?.countDown() } } @@ -843,6 +844,8 @@ class TrezorTransport @Inject constructor( Thread.sleep(100) } catch (e: Exception) { Logger.error("BLE close failed", e, context = TAG) + } finally { + userInitiatedCloseSet.remove(path) } Logger.info("BLE device closed: '$path'", context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt index a0ce997af..161c1d06d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/PairingCodeDialog.kt @@ -15,8 +15,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -73,10 +75,10 @@ internal fun PairingCodeDialog( unfocusedBorderColor = Colors.White32, cursorColor = Colors.Brand, ), - textStyle = androidx.compose.ui.text.TextStyle( + textStyle = TextStyle( fontFamily = FontFamily.Monospace, fontSize = 24.sp, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, letterSpacing = 8.sp, ), ) From a273e90b3803c0b50783259c955a2e4ad8384465 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Fri, 13 Mar 2026 08:37:18 -0400 Subject: [PATCH 26/48] chore: update bitkit-core to 0.1.47 --- app/src/main/java/to/bitkit/repositories/TrezorRepo.kt | 10 +++------- app/src/main/java/to/bitkit/services/TrezorService.kt | 3 +++ .../to/bitkit/ui/screens/trezor/TrezorViewModel.kt | 6 +++--- gradle/libs.versions.toml | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 44fc1b36e..a3eee0f6a 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -3,6 +3,7 @@ package to.bitkit.repositories import android.content.Context import androidx.compose.runtime.Stable import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType @@ -214,12 +215,14 @@ class TrezorRepo @Inject constructor( suspend fun getAccountInfo( extendedKey: String, network: BitkitCoreNetwork = Env.network.toCoreNetwork(), + scriptType: AccountType? = null, ): Result = withContext(bgDispatcher) { runCatching { trezorService.getAccountInfo( extendedKey = extendedKey, electrumUrl = electrumUrlForNetwork(network), network = keyFormatNetwork(network), + scriptType = scriptType, ) }.onFailure { e -> Logger.error("Trezor getAccountInfo failed", e, context = TAG) @@ -313,13 +316,6 @@ class TrezorRepo @Inject constructor( } } - fun coinStringForNetwork(network: TrezorCoinType): String = when (network) { - TrezorCoinType.BITCOIN -> "Bitcoin" - TrezorCoinType.TESTNET -> "Testnet" - TrezorCoinType.REGTEST -> "Regtest" - TrezorCoinType.SIGNET -> "Signet" - } - suspend fun disconnect(): Result = withContext(bgDispatcher) { runCatching { TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index a6ee1cf6d..a098614a9 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -1,6 +1,7 @@ package to.bitkit.services import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType @@ -256,6 +257,7 @@ class TrezorService @Inject constructor( electrumUrl: String, network: BitkitCoreNetwork?, gapLimit: UInt? = 20u, + scriptType: AccountType? = null, ): AccountInfoResult { return ServiceQueue.CORE.background { onchainGetAccountInfo( @@ -263,6 +265,7 @@ class TrezorService @Inject constructor( electrumUrl = electrumUrl, network = network, gapLimit = gapLimit, + scriptType = scriptType, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 4169a586e..2c4eda3e9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -411,11 +411,11 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(isComposing = true) } - val coinStr = trezorRepo.coinStringForNetwork(state.selectedNetwork.toTrezorCoinType()) + val coin = state.selectedNetwork.toTrezorCoinType() TrezorDebugLog.log("COMPOSE", "=== composeTx START ===") TrezorDebugLog.log("COMPOSE", "address=${state.sendAddress}") TrezorDebugLog.log("COMPOSE", "amount=${state.sendAmountSats}, sendMax=${state.isSendMax}") - TrezorDebugLog.log("COMPOSE", "feeRate=${state.sendFeeRate} sat/vB, coin=$coinStr") + TrezorDebugLog.log("COMPOSE", "feeRate=${state.sendFeeRate} sat/vB, coin=$coin") TrezorDebugLog.log("COMPOSE", "account.path=${accountInfo.account.path}") TrezorDebugLog.log("COMPOSE", "utxos=${accountInfo.account.utxo.size}, balance=${accountInfo.balance}") @@ -427,7 +427,7 @@ class TrezorViewModel @Inject constructor( val params = TrezorPrecomposeParams( outputs = listOf(output), - coin = coinStr, + coin = coin, account = accountInfo.account, feeLevels = listOf( TrezorFeeLevel(feePerUnit = state.sendFeeRate, baseFee = null, floorBaseFee = null) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b6b418ed..70e2e2d71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.45" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.47" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From 818da1750a83a354b8f92204d69f6902e005befd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:00:38 -0300 Subject: [PATCH 27/48] fix: remove dead code --- app/src/main/java/to/bitkit/ui/ContentView.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index c77169ee0..b62f1b3b3 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -1637,24 +1637,6 @@ sealed interface Routes { @Serializable data object Trezor : Routes - @Serializable - data object SweepNav : Routes - - @Serializable - data object Sweep : Routes - - @Serializable - data object SweepConfirm : Routes - - @Serializable - data object SweepFeeRate : Routes - - @Serializable - data object SweepFeeCustom : Routes - - @Serializable - data class SweepSuccess(val amountSats: Long) : Routes - @Serializable data object AboutSettings : Routes From c6fe3fc4404200b25c69d654765ee637b679be86 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:04:52 -0300 Subject: [PATCH 28/48] refactor: use Electrum address --- app/src/main/java/to/bitkit/env/Env.kt | 12 ++++++++++++ .../java/to/bitkit/repositories/TrezorRepo.kt | 17 ++++++----------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 319a4051a..14c109cf9 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -5,6 +5,7 @@ import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig +import com.synonym.bitkitcore.Network as BitkitCoreNetwork import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir import to.bitkit.ext.of @@ -155,6 +156,17 @@ internal object Env { else -> "https://bitkit.stag0.blocktank.to/backups-ldk" } + fun electrumUrlForNetwork(network: BitkitCoreNetwork): String { + val isE2eLocal = isE2eTest && e2eBackend == "local" + return when (network) { + BitkitCoreNetwork.BITCOIN -> ElectrumServers.MAINNET.ESPLORA + BitkitCoreNetwork.TESTNET, BitkitCoreNetwork.TESTNET4, BitkitCoreNetwork.SIGNET -> + ElectrumServers.TESTNET + BitkitCoreNetwork.REGTEST -> + if (isE2eLocal) ElectrumServers.REGTEST.LOCAL else ElectrumServers.REGTEST.STAG + } + } + // endregion // region paths diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index a3eee0f6a..23aa6c2f2 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -532,19 +532,14 @@ class TrezorRepo @Inject constructor( else -> network } - private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = when (network) { - BitkitCoreNetwork.BITCOIN -> "ssl://bitkit.to:9999" - BitkitCoreNetwork.TESTNET -> "ssl://electrum.blockstream.info:60002" - BitkitCoreNetwork.TESTNET4 -> "ssl://electrum.blockstream.info:60002" - BitkitCoreNetwork.REGTEST -> "ssl://electrs.bitkit.stag0.blocktank.to:9999" - BitkitCoreNetwork.SIGNET -> "ssl://electrum.blockstream.info:60002" - } + private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = + Env.electrumUrlForNetwork(network) private fun electrumUrlForNetwork(network: TrezorCoinType): String = when (network) { - TrezorCoinType.BITCOIN -> "ssl://bitkit.to:9999" - TrezorCoinType.TESTNET -> "ssl://electrum.blockstream.info:60002" - TrezorCoinType.REGTEST -> "ssl://electrs.bitkit.stag0.blocktank.to:9999" - TrezorCoinType.SIGNET -> "ssl://electrum.blockstream.info:60002" + TrezorCoinType.BITCOIN -> Env.electrumUrlForNetwork(BitkitCoreNetwork.BITCOIN) + TrezorCoinType.TESTNET -> Env.electrumUrlForNetwork(BitkitCoreNetwork.TESTNET) + TrezorCoinType.REGTEST -> Env.electrumUrlForNetwork(BitkitCoreNetwork.REGTEST) + TrezorCoinType.SIGNET -> Env.electrumUrlForNetwork(BitkitCoreNetwork.SIGNET) } private suspend fun ensureConnected() { From 8fb72c99a4183250d9d5563dc3608bcee184f4ab Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:07:10 -0300 Subject: [PATCH 29/48] refactor: replace bgDispatcher with ioDispatcher --- .../java/to/bitkit/repositories/TrezorRepo.kt | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 23aa6c2f2..bdeb8b101 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import to.bitkit.di.BgDispatcher +import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.models.toCoreNetwork import to.bitkit.services.TrezorDebugLog @@ -53,7 +53,7 @@ class TrezorRepo @Inject constructor( @ApplicationContext private val context: Context, private val trezorService: TrezorService, private val trezorTransport: TrezorTransport, - @BgDispatcher private val bgDispatcher: CoroutineDispatcher, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { private const val TAG = "TrezorRepo" @@ -91,7 +91,7 @@ class TrezorRepo @Inject constructor( trezorTransport.cancelPairingCode() } - suspend fun initialize(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + suspend fun initialize(walletIndex: Int = 0): Result = withContext(ioDispatcher) { runCatching { val credentialPath = "${Env.bitkitCoreStoragePath(walletIndex)}/trezor-credentials.json" Logger.debug("Initializing Trezor with credential path: '$credentialPath'", context = TAG) @@ -104,7 +104,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun scan(): Result> = withContext(bgDispatcher) { + suspend fun scan(): Result> = withContext(ioDispatcher) { runCatching { _state.update { it.copy(isScanning = true, error = null) } val devices = trezorService.scan() @@ -118,7 +118,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun listDevices(): Result> = withContext(bgDispatcher) { + suspend fun listDevices(): Result> = withContext(ioDispatcher) { runCatching { val devices = trezorService.listDevices() val knownIds = _state.value.knownDevices.map { it.id }.toSet() @@ -131,7 +131,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun connect(deviceId: String): Result = withContext(bgDispatcher) { + suspend fun connect(deviceId: String): Result = withContext(ioDispatcher) { runCatching { _state.update { it.copy(isConnecting = true, error = null) } TrezorDebugLog.log("CONNECT", "connect() called for deviceId=$deviceId") @@ -175,7 +175,7 @@ class TrezorRepo @Inject constructor( showOnTrezor: Boolean = false, scriptType: TrezorScriptType? = TrezorScriptType.SPEND_WITNESS, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { ensureConnected() val response = trezorService.getAddress( @@ -196,7 +196,7 @@ class TrezorRepo @Inject constructor( path: String = DEFAULT_ACCOUNT_PATH, showOnTrezor: Boolean = false, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { ensureConnected() val response = trezorService.getPublicKey( @@ -216,7 +216,7 @@ class TrezorRepo @Inject constructor( extendedKey: String, network: BitkitCoreNetwork = Env.network.toCoreNetwork(), scriptType: AccountType? = null, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { trezorService.getAccountInfo( extendedKey = extendedKey, @@ -233,7 +233,7 @@ class TrezorRepo @Inject constructor( suspend fun getAddressInfo( address: String, network: BitkitCoreNetwork = Env.network.toCoreNetwork(), - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { trezorService.getAddressInfo( address = address, @@ -248,7 +248,7 @@ class TrezorRepo @Inject constructor( suspend fun precomposeTransaction( params: TrezorPrecomposeParams, - ): Result> = withContext(bgDispatcher) { + ): Result> = withContext(ioDispatcher) { runCatching { trezorService.precomposeTransaction(params = params) }.onFailure { @@ -261,7 +261,7 @@ class TrezorRepo @Inject constructor( inputs: List, outputs: List, coin: TrezorCoinType?, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { trezorService.precomposedToSignParams( inputs = inputs, @@ -277,7 +277,7 @@ class TrezorRepo @Inject constructor( suspend fun fetchPrevTxs( txids: List, network: TrezorCoinType, - ): Result> = withContext(bgDispatcher) { + ): Result> = withContext(ioDispatcher) { runCatching { trezorService.fetchPrevTxs( txids = txids, @@ -292,7 +292,7 @@ class TrezorRepo @Inject constructor( suspend fun broadcastRawTx( serializedTx: String, network: BitkitCoreNetwork, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { trezorService.broadcastRawTx( serializedTx = serializedTx, @@ -304,7 +304,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun signTxWithParams(params: TrezorSignTxParams): Result = withContext(bgDispatcher) { + suspend fun signTxWithParams(params: TrezorSignTxParams): Result = withContext(ioDispatcher) { runCatching { ensureConnected() val response = trezorService.signTxWithParams(params) @@ -316,7 +316,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun disconnect(): Result = withContext(bgDispatcher) { + suspend fun disconnect(): Result = withContext(ioDispatcher) { runCatching { TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") runCatching { trezorService.disconnect() } @@ -335,7 +335,7 @@ class TrezorRepo @Inject constructor( path: String = DEFAULT_ADDRESS_PATH, message: String, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { ensureConnected() val response = trezorService.signMessage( @@ -356,7 +356,7 @@ class TrezorRepo @Inject constructor( signature: String, message: String, coin: TrezorCoinType = TrezorCoinType.BITCOIN, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { ensureConnected() val result = trezorService.verifyMessage( @@ -375,7 +375,7 @@ class TrezorRepo @Inject constructor( fun hasKnownDevices(): Boolean = _state.value.knownDevices.isNotEmpty() - suspend fun autoReconnect(walletIndex: Int = 0): Result = withContext(bgDispatcher) { + suspend fun autoReconnect(walletIndex: Int = 0): Result = withContext(ioDispatcher) { val knownDevices = _state.value.knownDevices.ifEmpty { loadKnownDevices() } if (knownDevices.isEmpty()) { return@withContext Result.failure(AppError("No known devices")) @@ -408,7 +408,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun connectKnownDevice(deviceId: String): Result = withContext(bgDispatcher) { + suspend fun connectKnownDevice(deviceId: String): Result = withContext(ioDispatcher) { if (_state.value.isConnecting) { return@withContext Result.failure(AppError("Connection already in progress")) } @@ -456,7 +456,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun forgetDevice(deviceId: String): Result = withContext(bgDispatcher) { + suspend fun forgetDevice(deviceId: String): Result = withContext(ioDispatcher) { runCatching { TrezorDebugLog.log("FORGET", "forgetDevice called for: $deviceId") if (_state.value.connectedDeviceId == deviceId) { @@ -563,7 +563,7 @@ class TrezorRepo @Inject constructor( coin: TrezorCoinType = TrezorCoinType.BITCOIN, lockTime: UInt? = null, version: UInt? = null, - ): Result = withContext(bgDispatcher) { + ): Result = withContext(ioDispatcher) { runCatching { ensureConnected() val response = trezorService.signTx( @@ -581,7 +581,7 @@ class TrezorRepo @Inject constructor( } } - suspend fun clearCredentials(deviceId: String): Result = withContext(bgDispatcher) { + suspend fun clearCredentials(deviceId: String): Result = withContext(ioDispatcher) { runCatching { trezorService.clearCredentials(deviceId) _state.update { it.copy(error = null) } From 5e39fdd25938cd4b03e23053cd0cbd4f882f51d0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:09:00 -0300 Subject: [PATCH 30/48] refactor: convert connectWithThpRetry to Result API --- .../main/java/to/bitkit/repositories/TrezorRepo.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index bdeb8b101..90d427e7d 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -591,16 +591,15 @@ class TrezorRepo @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") private suspend fun connectWithThpRetry(deviceId: String): TrezorFeatures { TrezorDebugLog.log("THPRetry", "First connect attempt for: $deviceId") logCredentialFileState(deviceId, "BEFORE 1st attempt") - return try { - val result = trezorService.connect(deviceId) + return runCatching { + trezorService.connect(deviceId) + }.onSuccess { logCredentialFileState(deviceId, "AFTER 1st attempt (success)") TrezorDebugLog.log("THPRetry", "First attempt succeeded") - result - } catch (e: Exception) { + }.getOrElse { e -> logCredentialFileState(deviceId, "AFTER 1st attempt (failed)") TrezorDebugLog.log("THPRetry", "First attempt failed: ${e.message}") if (!isRetryableError(e)) { @@ -626,7 +625,7 @@ class TrezorRepo @Inject constructor( TrezorDebugLog.log("CRED", "$label: file=$sanitizedId.json exists=$exists size=$size") } - private fun isRetryableError(e: Exception): Boolean { + private fun isRetryableError(e: Throwable): Boolean { val msg = e.message?.lowercase() ?: return false return "thp" in msg || "session" in msg || "timeout" in msg || "disconnect" in msg } From 1828bcdbd93c2e043f9fc03938b954bec137f2b4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:09:50 -0300 Subject: [PATCH 31/48] chore: add missing trailing newlines --- app/proguard-rules.pro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 97f12cb0e..ae39109b6 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -25,4 +25,4 @@ -keep class com.nonpolynomial.btleplug.** { *; } # jni-utils support library for btleplug --keep class io.github.gedgygedgy.rust.** { *; } \ No newline at end of file +-keep class io.github.gedgygedgy.rust.** { *; } From 5d28d92dcea62c86356bc9837f37e0a90e819adf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:16:51 -0300 Subject: [PATCH 32/48] test: add basic tests --- app/src/main/java/to/bitkit/env/Env.kt | 2 +- .../ui/screens/trezor/TrezorViewModelTest.kt | 274 ++++++++++++++++++ 2 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 14c109cf9..50c6ca4d7 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -5,7 +5,6 @@ import org.lightningdevkit.ldknode.LogLevel import org.lightningdevkit.ldknode.Network import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.BuildConfig -import com.synonym.bitkitcore.Network as BitkitCoreNetwork import to.bitkit.BuildConfig.VERSION_NAME import to.bitkit.ext.ensureDir import to.bitkit.ext.of @@ -14,6 +13,7 @@ import to.bitkit.models.NodePeer import to.bitkit.utils.Logger import java.io.File import kotlin.io.path.Path +import com.synonym.bitkitcore.Network as BitkitCoreNetwork @Suppress("ConstPropertyName", "KotlinConstantConditions", "SimplifyBooleanWithConstants") internal object Env { diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt new file mode 100644 index 000000000..a0cb75ab6 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -0,0 +1,274 @@ +package to.bitkit.ui.screens.trezor + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.repositories.TrezorRepo +import to.bitkit.repositories.TrezorState +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import com.synonym.bitkitcore.Network as BitkitCoreNetwork + +@OptIn(ExperimentalCoroutinesApi::class) +class TrezorViewModelTest : BaseUnitTest() { + + private val trezorRepo = mock() + + private lateinit var viewModel: TrezorViewModel + + @Before + fun setup() = runBlocking { + val stateFlow = MutableStateFlow(TrezorState()) + whenever(trezorRepo.state).thenReturn(stateFlow.asStateFlow()) + whenever(trezorRepo.needsPairingCode).thenReturn(MutableStateFlow(false).asStateFlow()) + + viewModel = TrezorViewModel( + bgDispatcher = testDispatcher, + trezorRepo = trezorRepo, + ) + } + + // region Initial State + + @Test + fun `initial uiState should have default derivation path`() { + val state = viewModel.uiState.value + + assertTrue(state.derivationPath.startsWith("m/84'/")) + assertTrue(state.derivationPath.endsWith("/0/0")) + } + + @Test + fun `initial uiState should have addressIndex 0`() { + val state = viewModel.uiState.value + + assertEquals(0, state.addressIndex) + } + + @Test + fun `initial uiState should not be in any loading state`() { + val state = viewModel.uiState.value + + assertFalse(state.isGettingAddress) + assertFalse(state.isGettingPublicKey) + assertFalse(state.isSigningMessage) + assertFalse(state.isVerifyingMessage) + assertFalse(state.isLookingUp) + assertFalse(state.isComposing) + assertFalse(state.isSigning) + assertFalse(state.isBroadcasting) + } + + @Test + fun `initial uiState should have empty lookup input`() { + val state = viewModel.uiState.value + + assertEquals("", state.lookupInput) + } + + @Test + fun `initial uiState should have no results`() { + val state = viewModel.uiState.value + + assertNull(state.accountInfoResult) + assertNull(state.addressInfoResult) + assertNull(state.lastSignature) + assertNull(state.lastSigningAddress) + assertNull(state.precomposedResult) + assertNull(state.signedTxResult) + assertNull(state.broadcastTxid) + } + + @Test + fun `initial uiState should have FORM send step`() { + val state = viewModel.uiState.value + + assertEquals(SendStep.FORM, state.sendStep) + } + + // endregion + + // region Network Selection + + @Test + fun `setSelectedNetwork to BITCOIN should update coin type to 0`() { + viewModel.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + + val state = viewModel.uiState.value + assertEquals(BitkitCoreNetwork.BITCOIN, state.selectedNetwork) + assertEquals("m/84'/0'/0'/0/0", state.derivationPath) + } + + @Test + fun `setSelectedNetwork to TESTNET should update coin type to 1`() { + viewModel.setSelectedNetwork(BitkitCoreNetwork.TESTNET) + + val state = viewModel.uiState.value + assertEquals(BitkitCoreNetwork.TESTNET, state.selectedNetwork) + assertEquals("m/84'/1'/0'/0/0", state.derivationPath) + } + + @Test + fun `setSelectedNetwork to REGTEST should update coin type to 1`() { + viewModel.setSelectedNetwork(BitkitCoreNetwork.REGTEST) + + val state = viewModel.uiState.value + assertEquals(BitkitCoreNetwork.REGTEST, state.selectedNetwork) + assertEquals("m/84'/1'/0'/0/0", state.derivationPath) + } + + @Test + fun `setSelectedNetwork should reset addressIndex to 0`() { + viewModel.incrementAddressIndex() + viewModel.incrementAddressIndex() + assertEquals(2, viewModel.uiState.value.addressIndex) + + viewModel.setSelectedNetwork(BitkitCoreNetwork.TESTNET) + + assertEquals(0, viewModel.uiState.value.addressIndex) + } + + // endregion + + // region Address Index + + @Test + fun `incrementAddressIndex should increment index and update path`() { + viewModel.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + + viewModel.incrementAddressIndex() + + val state = viewModel.uiState.value + assertEquals(1, state.addressIndex) + assertEquals("m/84'/0'/0'/0/1", state.derivationPath) + } + + @Test + fun `incrementAddressIndex multiple times should increment correctly`() { + viewModel.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + + viewModel.incrementAddressIndex() + viewModel.incrementAddressIndex() + viewModel.incrementAddressIndex() + + val state = viewModel.uiState.value + assertEquals(3, state.addressIndex) + assertEquals("m/84'/0'/0'/0/3", state.derivationPath) + } + + // endregion + + // region Derivation Path + + @Test + fun `setDerivationPath should update path`() { + viewModel.setDerivationPath("m/49'/0'/0'/0/0") + + assertEquals("m/49'/0'/0'/0/0", viewModel.uiState.value.derivationPath) + } + + // endregion + + // region Message Signing State + + @Test + fun `setMessageToSign should update message`() { + viewModel.setMessageToSign("Test message") + + assertEquals("Test message", viewModel.uiState.value.messageToSign) + } + + @Test + fun `initial message should be default greeting`() { + assertEquals("Hello, Trezor!", viewModel.uiState.value.messageToSign) + } + + // endregion + + // region Lookup Input + + @Test + fun `setLookupInput should update input`() { + viewModel.setLookupInput("xpub6C...") + + assertEquals("xpub6C...", viewModel.uiState.value.lookupInput) + } + + // endregion + + // region Send Flow State + + @Test + fun `setSendAddress should update address`() { + viewModel.setSendAddress("bc1qtest...") + + assertEquals("bc1qtest...", viewModel.uiState.value.sendAddress) + } + + @Test + fun `setSendAmount should update amount`() { + viewModel.setSendAmount("50000") + + assertEquals("50000", viewModel.uiState.value.sendAmountSats) + } + + @Test + fun `setSendFeeRate should update fee rate`() { + viewModel.setSendFeeRate("10") + + assertEquals("10", viewModel.uiState.value.sendFeeRate) + } + + @Test + fun `toggleSendMax should toggle isSendMax`() { + assertFalse(viewModel.uiState.value.isSendMax) + + viewModel.toggleSendMax() + assertTrue(viewModel.uiState.value.isSendMax) + + viewModel.toggleSendMax() + assertFalse(viewModel.uiState.value.isSendMax) + } + + @Test + fun `resetSendFlow should clear all send fields`() { + viewModel.setSendAddress("bc1qtest") + viewModel.setSendAmount("50000") + viewModel.setSendFeeRate("10") + viewModel.toggleSendMax() + + viewModel.resetSendFlow() + + val state = viewModel.uiState.value + assertEquals("", state.sendAddress) + assertEquals("", state.sendAmountSats) + assertEquals("2", state.sendFeeRate) + assertFalse(state.isSendMax) + assertFalse(state.isComposing) + assertFalse(state.isSigning) + assertNull(state.precomposedResult) + assertNull(state.signedTxResult) + assertEquals(SendStep.FORM, state.sendStep) + assertNull(state.broadcastTxid) + } + + @Test + fun `backToComposeForm should reset to FORM step`() { + viewModel.backToComposeForm() + + val state = viewModel.uiState.value + assertEquals(SendStep.FORM, state.sendStep) + assertNull(state.precomposedResult) + assertNull(state.signedTxResult) + } + + // endregion +} From f005d16a7be4f0f2d9fed828200d2d315320e835 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:30:55 -0300 Subject: [PATCH 33/48] chore: log cleanup --- app/src/main/java/to/bitkit/repositories/TrezorRepo.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 90d427e7d..3cb4f0ea6 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -607,7 +607,7 @@ class TrezorRepo @Inject constructor( throw e } TrezorDebugLog.log("THPRetry", "Error is retryable, attempting second connect...") - Logger.warn("Connection failed for '$deviceId', retrying", e, context = TAG) + Logger.warn("Connection failed for $deviceId, retrying", e, context = TAG) logCredentialFileState(deviceId, "BEFORE 2nd attempt") val result = trezorService.connect(deviceId) logCredentialFileState(deviceId, "AFTER 2nd attempt (success)") From c0ccc80bc000368485598841f463db16392a2883 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:36:55 -0300 Subject: [PATCH 34/48] refactor: composable name --- .../java/to/bitkit/ui/screens/trezor/TrezorScreen.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 74be2e2cd..890233e23 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -122,7 +122,7 @@ private fun TrezorScreenContent( onBackClick = onBack, actions = { DrawerNavIcon() }, ) - TrezorContent( + Content( trezorState = trezorState, uiState = uiState, onInitialize = viewModel::initialize, @@ -158,7 +158,7 @@ private fun TrezorScreenContent( @Suppress("LongParameterList") @Composable -private fun TrezorContent( +private fun Content( trezorState: TrezorState, uiState: TrezorUiState, onInitialize: () -> Unit = {}, @@ -620,7 +620,7 @@ private fun ActionButtonsRow( @Composable private fun PreviewNotInitialized() { AppThemeSurface { - TrezorContent( + Content( trezorState = TrezorState(), uiState = TrezorUiState(), ) @@ -631,7 +631,7 @@ private fun PreviewNotInitialized() { @Composable private fun PreviewInitialized() { AppThemeSurface { - TrezorContent( + Content( trezorState = TrezorState(isInitialized = true), uiState = TrezorUiState(), ) @@ -642,7 +642,7 @@ private fun PreviewInitialized() { @Composable private fun PreviewWithDevices() { AppThemeSurface { - TrezorContent( + Content( trezorState = TrezorState( isInitialized = true, knownDevices = listOf(TrezorPreviewData.sampleKnownDevice), @@ -658,7 +658,7 @@ private fun PreviewWithDevices() { @Composable private fun PreviewConnectedWithData() { AppThemeSurface { - TrezorContent( + Content( trezorState = TrezorPreviewData.connectedStateWithResults, uiState = TrezorPreviewData.uiStateWithSignature, ) From f5c05ca2c25975af342e621a0ab0fae3fb9296f4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 16 Mar 2026 14:51:02 -0300 Subject: [PATCH 35/48] refactor: replace shared preferences with DataStore --- .../main/java/to/bitkit/data/TrezorStore.kt | 43 +++++++++++++++++++ .../data/serializers/TrezorDataSerializer.kt | 26 +++++++++++ .../java/to/bitkit/repositories/TrezorRepo.kt | 22 +++------- 3 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/to/bitkit/data/TrezorStore.kt create mode 100644 app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt diff --git a/app/src/main/java/to/bitkit/data/TrezorStore.kt b/app/src/main/java/to/bitkit/data/TrezorStore.kt new file mode 100644 index 000000000..12aceb195 --- /dev/null +++ b/app/src/main/java/to/bitkit/data/TrezorStore.kt @@ -0,0 +1,43 @@ +package to.bitkit.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.dataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.Serializable +import to.bitkit.data.serializers.TrezorDataSerializer +import to.bitkit.repositories.KnownDevice +import javax.inject.Inject +import javax.inject.Singleton + +private val Context.trezorDataStore: DataStore by dataStore( + fileName = "trezor_device.json", + serializer = TrezorDataSerializer +) + +@Singleton +class TrezorStore @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val store = context.trezorDataStore + + val data: Flow = store.data + + suspend fun loadKnownDevices(): List = + store.data.first().knownDevices + + suspend fun saveKnownDevices(devices: List) { + store.updateData { it.copy(knownDevices = devices) } + } + + suspend fun reset() { + store.updateData { TrezorData() } + } +} + +@Serializable +data class TrezorData( + val knownDevices: List = emptyList(), +) diff --git a/app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt b/app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt new file mode 100644 index 000000000..b9556998b --- /dev/null +++ b/app/src/main/java/to/bitkit/data/serializers/TrezorDataSerializer.kt @@ -0,0 +1,26 @@ +package to.bitkit.data.serializers + +import androidx.datastore.core.Serializer +import kotlinx.serialization.SerializationException +import to.bitkit.data.TrezorData +import to.bitkit.di.json +import to.bitkit.utils.Logger +import java.io.InputStream +import java.io.OutputStream + +object TrezorDataSerializer : Serializer { + override val defaultValue: TrezorData = TrezorData() + + override suspend fun readFrom(input: InputStream): TrezorData { + return try { + json.decodeFromString(input.readBytes().decodeToString()) + } catch (e: SerializationException) { + Logger.error("Failed to deserialize: $e") + defaultValue + } + } + + override suspend fun writeTo(t: TrezorData, output: OutputStream) { + output.write(json.encodeToString(t).encodeToByteArray()) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 3cb4f0ea6..5a8fbbb5f 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -32,8 +32,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import to.bitkit.data.TrezorStore import to.bitkit.di.IoDispatcher import to.bitkit.env.Env import to.bitkit.models.toCoreNetwork @@ -53,21 +52,15 @@ class TrezorRepo @Inject constructor( @ApplicationContext private val context: Context, private val trezorService: TrezorService, private val trezorTransport: TrezorTransport, + private val trezorStore: TrezorStore, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { companion object { private const val TAG = "TrezorRepo" - private const val KEY_KNOWN_DEVICES = "known_devices" private const val DEFAULT_ADDRESS_PATH = "m/84'/0'/0'/0/0" private const val DEFAULT_ACCOUNT_PATH = "m/84'/0'/0'" } - private val prefs by lazy { - context.getSharedPreferences("trezor_device", Context.MODE_PRIVATE) - } - - private val json = Json { ignoreUnknownKeys = true } - private val _state = MutableStateFlow(TrezorState()) val state = _state.asStateFlow() @@ -495,7 +488,7 @@ class TrezorRepo @Inject constructor( }.launchIn(scope) } - private fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { + private suspend fun addOrUpdateKnownDevice(deviceInfo: TrezorDeviceInfo, features: TrezorFeatures) { val existing = _state.value.knownDevices val known = KnownDevice( id = deviceInfo.id, @@ -514,16 +507,15 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(knownDevices = updated) } } - private fun loadKnownDevices(): List = runCatching { - val str = prefs.getString(KEY_KNOWN_DEVICES, null) ?: return emptyList() - json.decodeFromString>(str) + private suspend fun loadKnownDevices(): List = runCatching { + trezorStore.loadKnownDevices() }.onFailure { Logger.error("Failed to load known devices", it, context = TAG) }.getOrDefault(emptyList()) - private fun saveKnownDevices(devices: List) { + private suspend fun saveKnownDevices(devices: List) { runCatching { - prefs.edit().putString(KEY_KNOWN_DEVICES, json.encodeToString(devices)).apply() + trezorStore.saveKnownDevices(devices) }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } From a40e6afcf074a2825d1df7606ddcdc03f97ada40 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 17 Mar 2026 07:31:10 -0400 Subject: [PATCH 36/48] chore: upgrade bitkit-core to 0.1.48 --- .../java/to/bitkit/repositories/TrezorRepo.kt | 125 ++---- .../java/to/bitkit/services/TrezorService.kt | 66 +-- .../ui/screens/trezor/BalanceLookupSection.kt | 18 +- .../screens/trezor/SendTransactionSection.kt | 141 +++--- .../ui/screens/trezor/TrezorPreviewData.kt | 40 +- .../bitkit/ui/screens/trezor/TrezorScreen.kt | 8 +- .../ui/screens/trezor/TrezorViewModel.kt | 222 +++------- .../to/bitkit/repositories/TrezorRepoTest.kt | 403 ++++++++++++++++++ .../ui/screens/trezor/TrezorViewModelTest.kt | 308 ++++++------- gradle/libs.versions.toml | 2 +- 10 files changed, 754 insertions(+), 579 deletions(-) create mode 100644 app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 5a8fbbb5f..16abe277a 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -4,24 +4,21 @@ import android.content.Context import androidx.compose.runtime.Stable import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.CoinSelection +import com.synonym.bitkitcore.ComposeOutput +import com.synonym.bitkitcore.ComposeParams +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures -import com.synonym.bitkitcore.TrezorPrecomposeParams -import com.synonym.bitkitcore.TrezorPrecomposedInput -import com.synonym.bitkitcore.TrezorPrecomposedOutput -import com.synonym.bitkitcore.TrezorPrecomposedResult -import com.synonym.bitkitcore.TrezorPrevTx import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorScriptType -import com.synonym.bitkitcore.TrezorSignTxParams import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTransportType -import com.synonym.bitkitcore.TrezorTxInput -import com.synonym.bitkitcore.TrezorTxOutput +import com.synonym.bitkitcore.WalletParams import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -214,7 +211,7 @@ class TrezorRepo @Inject constructor( trezorService.getAccountInfo( extendedKey = extendedKey, electrumUrl = electrumUrlForNetwork(network), - network = keyFormatNetwork(network), + network = network, scriptType = scriptType, ) }.onFailure { e -> @@ -231,7 +228,7 @@ class TrezorRepo @Inject constructor( trezorService.getAddressInfo( address = address, electrumUrl = electrumUrlForNetwork(network), - network = keyFormatNetwork(network), + network = network, ) }.onFailure { e -> Logger.error("Trezor getAddressInfo failed", e, context = TAG) @@ -239,45 +236,47 @@ class TrezorRepo @Inject constructor( } } - suspend fun precomposeTransaction( - params: TrezorPrecomposeParams, - ): Result> = withContext(ioDispatcher) { - runCatching { - trezorService.precomposeTransaction(params = params) - }.onFailure { - Logger.error("Trezor precomposeTransaction failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } - } - } - - suspend fun convertToSignParams( - inputs: List, - outputs: List, - coin: TrezorCoinType?, - ): Result = withContext(ioDispatcher) { + @Suppress("LongParameterList") + suspend fun composeTransaction( + extendedKey: String, + outputs: List, + feeRates: List, + network: BitkitCoreNetwork, + accountType: AccountType?, + coinSelection: CoinSelection, + ): Result> = withContext(ioDispatcher) { runCatching { - trezorService.precomposedToSignParams( - inputs = inputs, + val fingerprint = trezorService.getDeviceFingerprint() + val params = ComposeParams( + wallet = WalletParams( + extendedKey = extendedKey, + electrumUrl = electrumUrlForNetwork(network), + fingerprint = fingerprint, + network = network, + accountType = accountType, + ), outputs = outputs, - coin = coin, + feeRates = feeRates, + coinSelection = coinSelection, ) + trezorService.composeTransaction(params) }.onFailure { - Logger.error("Trezor convertToSignParams failed", it, context = TAG) + Logger.error("Trezor composeTransaction failed", it, context = TAG) _state.update { s -> s.copy(error = it.message) } } } - suspend fun fetchPrevTxs( - txids: List, - network: TrezorCoinType, - ): Result> = withContext(ioDispatcher) { + suspend fun signTxFromPsbt( + psbtBase64: String, + network: TrezorCoinType?, + ): Result = withContext(ioDispatcher) { runCatching { - trezorService.fetchPrevTxs( - txids = txids, - electrumUrl = electrumUrlForNetwork(network), - ) + ensureConnected() + val response = trezorService.signTxFromPsbt(psbtBase64, network) + _state.update { it.copy(error = null) } + response }.onFailure { - Logger.error("Trezor fetchPrevTxs failed", it, context = TAG) + Logger.error("Trezor signTxFromPsbt failed", it, context = TAG) _state.update { s -> s.copy(error = it.message) } } } @@ -297,18 +296,6 @@ class TrezorRepo @Inject constructor( } } - suspend fun signTxWithParams(params: TrezorSignTxParams): Result = withContext(ioDispatcher) { - runCatching { - ensureConnected() - val response = trezorService.signTxWithParams(params) - _state.update { it.copy(error = null) } - response - }.onFailure { - Logger.error("Trezor signTxWithParams failed", it, context = TAG) - _state.update { s -> s.copy(error = it.message) } - } - } - suspend fun disconnect(): Result = withContext(ioDispatcher) { runCatching { TrezorDebugLog.log("DISCONNECT", "disconnect() called, connectedDeviceId=${_state.value.connectedDeviceId}") @@ -519,14 +506,14 @@ class TrezorRepo @Inject constructor( }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } - private fun keyFormatNetwork(network: BitkitCoreNetwork): BitkitCoreNetwork = when (network) { - BitkitCoreNetwork.REGTEST -> BitkitCoreNetwork.TESTNET - else -> network + private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = when (network) { + BitkitCoreNetwork.BITCOIN -> "ssl://bitkit.to:9999" + BitkitCoreNetwork.TESTNET -> "ssl://electrum.blockstream.info:60002" + BitkitCoreNetwork.TESTNET4 -> "ssl://electrum.blockstream.info:60002" + BitkitCoreNetwork.REGTEST -> "ssl://electrs.bitkit.stag0.blocktank.to:9999" + BitkitCoreNetwork.SIGNET -> "ssl://electrum.blockstream.info:60002" } - private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = - Env.electrumUrlForNetwork(network) - private fun electrumUrlForNetwork(network: TrezorCoinType): String = when (network) { TrezorCoinType.BITCOIN -> Env.electrumUrlForNetwork(BitkitCoreNetwork.BITCOIN) TrezorCoinType.TESTNET -> Env.electrumUrlForNetwork(BitkitCoreNetwork.TESTNET) @@ -549,30 +536,6 @@ class TrezorRepo @Inject constructor( _state.update { it.copy(connectedDevice = features, connectedDeviceId = deviceId) } } - suspend fun signTx( - inputs: List, - outputs: List, - coin: TrezorCoinType = TrezorCoinType.BITCOIN, - lockTime: UInt? = null, - version: UInt? = null, - ): Result = withContext(ioDispatcher) { - runCatching { - ensureConnected() - val response = trezorService.signTx( - inputs = inputs, - outputs = outputs, - coin = coin, - lockTime = lockTime, - version = version, - ) - _state.update { it.copy(error = null) } - response - }.onFailure { e -> - Logger.error("Trezor signTx failed", e, context = TAG) - _state.update { it.copy(error = e.message) } - } - } - suspend fun clearCredentials(deviceId: String): Result = withContext(ioDispatcher) { runCatching { trezorService.clearCredentials(deviceId) diff --git a/app/src/main/java/to/bitkit/services/TrezorService.kt b/app/src/main/java/to/bitkit/services/TrezorService.kt index a098614a9..042f8c285 100644 --- a/app/src/main/java/to/bitkit/services/TrezorService.kt +++ b/app/src/main/java/to/bitkit/services/TrezorService.kt @@ -2,6 +2,8 @@ package to.bitkit.services import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType +import com.synonym.bitkitcore.ComposeParams +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorCoinType @@ -9,40 +11,31 @@ import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures import com.synonym.bitkitcore.TrezorGetAddressParams import com.synonym.bitkitcore.TrezorGetPublicKeyParams -import com.synonym.bitkitcore.TrezorPrecomposeParams -import com.synonym.bitkitcore.TrezorPrecomposedInput -import com.synonym.bitkitcore.TrezorPrecomposedOutput -import com.synonym.bitkitcore.TrezorPrecomposedResult -import com.synonym.bitkitcore.TrezorPrevTx import com.synonym.bitkitcore.TrezorPublicKeyResponse import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignMessageParams -import com.synonym.bitkitcore.TrezorSignTxParams import com.synonym.bitkitcore.TrezorSignedMessageResponse import com.synonym.bitkitcore.TrezorSignedTx -import com.synonym.bitkitcore.TrezorTxInput -import com.synonym.bitkitcore.TrezorTxOutput import com.synonym.bitkitcore.TrezorVerifyMessageParams import com.synonym.bitkitcore.onchainBroadcastRawTx +import com.synonym.bitkitcore.onchainComposeTransaction import com.synonym.bitkitcore.onchainGetAccountInfo import com.synonym.bitkitcore.onchainGetAddressInfo import com.synonym.bitkitcore.trezorClearCredentials import com.synonym.bitkitcore.trezorConnect import com.synonym.bitkitcore.trezorDisconnect -import com.synonym.bitkitcore.trezorFetchPrevTxs import com.synonym.bitkitcore.trezorGetAddress import com.synonym.bitkitcore.trezorGetConnectedDevice +import com.synonym.bitkitcore.trezorGetDeviceFingerprint import com.synonym.bitkitcore.trezorGetPublicKey import com.synonym.bitkitcore.trezorInitialize import com.synonym.bitkitcore.trezorIsConnected import com.synonym.bitkitcore.trezorIsInitialized import com.synonym.bitkitcore.trezorListDevices -import com.synonym.bitkitcore.trezorPrecomposeTransaction -import com.synonym.bitkitcore.trezorPrecomposedToSignParams import com.synonym.bitkitcore.trezorScan import com.synonym.bitkitcore.trezorSetTransportCallback import com.synonym.bitkitcore.trezorSignMessage -import com.synonym.bitkitcore.trezorSignTx +import com.synonym.bitkitcore.trezorSignTxFromPsbt import com.synonym.bitkitcore.trezorVerifyMessage import to.bitkit.async.ServiceQueue import javax.inject.Inject @@ -185,64 +178,27 @@ class TrezorService @Inject constructor( } } - suspend fun signTx( - inputs: List, - outputs: List, - coin: TrezorCoinType? = TrezorCoinType.BITCOIN, - lockTime: UInt? = null, - version: UInt? = null, - ): TrezorSignedTx { - return ServiceQueue.CORE.background { - trezorSignTx( - params = TrezorSignTxParams( - inputs = inputs, - outputs = outputs, - coin = coin, - lockTime = lockTime, - version = version, - prevTxs = emptyList(), - ) - ) - } - } - suspend fun clearCredentials(deviceId: String) { ServiceQueue.CORE.background { trezorClearCredentials(deviceId = deviceId) } } - suspend fun precomposeTransaction( - params: TrezorPrecomposeParams, - ): List { - return ServiceQueue.CORE.background { - trezorPrecomposeTransaction(params = params) - } - } - - suspend fun precomposedToSignParams( - inputs: List, - outputs: List, - coin: TrezorCoinType?, - ): TrezorSignTxParams { + suspend fun composeTransaction(params: ComposeParams): List { return ServiceQueue.CORE.background { - trezorPrecomposedToSignParams( - inputs = inputs, - outputs = outputs, - coin = coin, - ) + onchainComposeTransaction(params = params) } } - suspend fun signTxWithParams(params: TrezorSignTxParams): TrezorSignedTx { + suspend fun signTxFromPsbt(psbtBase64: String, network: TrezorCoinType?): TrezorSignedTx { return ServiceQueue.CORE.background { - trezorSignTx(params = params) + trezorSignTxFromPsbt(psbtBase64, network) } } - suspend fun fetchPrevTxs(txids: List, electrumUrl: String): List { + suspend fun getDeviceFingerprint(): String { return ServiceQueue.CORE.background { - trezorFetchPrevTxs(txids = txids, electrumUrl = electrumUrl) + trezorGetDeviceFingerprint() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index 613adc367..b9cb5c2c4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -20,8 +20,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountUtxo +import com.synonym.bitkitcore.CoinSelection import com.synonym.bitkitcore.SingleAddressInfoResult -import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption @@ -45,7 +45,7 @@ internal fun BalanceLookupSection( onSendAmountChange: (String) -> Unit, onSendFeeRateChange: (String) -> Unit, onToggleSendMax: () -> Unit, - onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCoinSelectionChange: (CoinSelection) -> Unit, onCompose: () -> Unit, onSign: () -> Unit, onBroadcast: () -> Unit, @@ -94,7 +94,7 @@ internal fun BalanceLookupSection( onSendAmountChange = onSendAmountChange, onSendFeeRateChange = onSendFeeRateChange, onToggleSendMax = onToggleSendMax, - onSortingStrategyChange = onSortingStrategyChange, + onCoinSelectionChange = onCoinSelectionChange, onCompose = onCompose, onSign = onSign, onBroadcast = onBroadcast, @@ -120,7 +120,7 @@ private fun AccountInfoResultView( onSendAmountChange: (String) -> Unit, onSendFeeRateChange: (String) -> Unit, onToggleSendMax: () -> Unit, - onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCoinSelectionChange: (CoinSelection) -> Unit, onCompose: () -> Unit, onSign: () -> Unit, onBroadcast: () -> Unit, @@ -158,7 +158,7 @@ private fun AccountInfoResultView( onAmountChange = onSendAmountChange, onFeeRateChange = onSendFeeRateChange, onToggleSendMax = onToggleSendMax, - onSortingStrategyChange = onSortingStrategyChange, + onCoinSelectionChange = onCoinSelectionChange, onCompose = onCompose, onSign = onSign, onBroadcast = onBroadcast, @@ -270,7 +270,7 @@ private fun PreviewBalanceLookupEmpty() { onSendAmountChange = {}, onSendFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, @@ -293,7 +293,7 @@ private fun PreviewBalanceLookupWithAccountInfo() { onSendAmountChange = {}, onSendFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, @@ -316,7 +316,7 @@ private fun PreviewBalanceLookupWithAddressInfo() { onSendAmountChange = {}, onSendFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, @@ -339,7 +339,7 @@ private fun PreviewBalanceLookupLoading() { onSendAmountChange = {}, onSendFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt index 2a79a9724..ed6b76f1a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/SendTransactionSection.kt @@ -16,10 +16,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.synonym.bitkitcore.TrezorPrecomposedOutput -import com.synonym.bitkitcore.TrezorPrecomposedResult +import com.synonym.bitkitcore.CoinSelection +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.TrezorSignedTx -import com.synonym.bitkitcore.TrezorSortingStrategy import to.bitkit.R import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption @@ -54,7 +53,7 @@ internal fun SendTransactionSection( onAmountChange: (String) -> Unit, onFeeRateChange: (String) -> Unit, onToggleSendMax: () -> Unit, - onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCoinSelectionChange: (CoinSelection) -> Unit, onCompose: () -> Unit, onSign: () -> Unit, onBroadcast: () -> Unit, @@ -76,10 +75,10 @@ internal fun SendTransactionSection( onAmountChange = onAmountChange, onFeeRateChange = onFeeRateChange, onToggleSendMax = onToggleSendMax, - onSortingStrategyChange = onSortingStrategyChange, + onCoinSelectionChange = onCoinSelectionChange, onCompose = onCompose, ) - SendStep.REVIEW -> uiState.precomposedResult?.let { result -> + SendStep.REVIEW -> uiState.composeResult?.let { result -> ReviewSection( result = result, isDeviceConnected = isDeviceConnected, @@ -108,7 +107,7 @@ private fun ComposeForm( onAmountChange: (String) -> Unit, onFeeRateChange: (String) -> Unit, onToggleSendMax: () -> Unit, - onSortingStrategyChange: (TrezorSortingStrategy) -> Unit, + onCoinSelectionChange: (CoinSelection) -> Unit, onCompose: () -> Unit, ) { Column { @@ -165,9 +164,9 @@ private fun ComposeForm( color = Colors.White64, ) VerticalSpacer(4.dp) - SortingStrategyRow( - selected = uiState.sortingStrategy, - onChange = onSortingStrategyChange, + CoinSelectionRow( + selected = uiState.coinSelection, + onChange = onCoinSelectionChange, ) VerticalSpacer(16.dp) @@ -185,21 +184,21 @@ private fun ComposeForm( } @Composable -private fun SortingStrategyRow( - selected: TrezorSortingStrategy, - onChange: (TrezorSortingStrategy) -> Unit, +private fun CoinSelectionRow( + selected: CoinSelection, + onChange: (CoinSelection) -> Unit, ) { val labels = mapOf( - TrezorSortingStrategy.BIP69 to "BIP69", - TrezorSortingStrategy.RANDOM to "Random", - TrezorSortingStrategy.NONE to "None", + CoinSelection.BRANCH_AND_BOUND to "Branch & Bound", + CoinSelection.LARGEST_FIRST to "Largest First", + CoinSelection.OLDEST_FIRST to "Oldest First", ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TrezorSortingStrategy.entries.forEach { strategy -> + CoinSelection.entries.forEach { selection -> TagButton( - text = labels[strategy] ?: strategy.name, - onClick = { onChange(strategy) }, - isSelected = strategy == selected, + text = labels[selection] ?: selection.name, + onClick = { onChange(selection) }, + isSelected = selection == selected, ) } } @@ -207,52 +206,47 @@ private fun SortingStrategyRow( @Composable private fun ReviewSection( - result: TrezorPrecomposedResult.Final, + result: ComposeResult.Success, isDeviceConnected: Boolean, isSigning: Boolean, onSign: () -> Unit, onBack: () -> Unit, ) { + val onCopyPsbt = copyToClipboard(text = result.psbt, label = "PSBT") + Column { ResultCard { InfoRow("Total Spent", "${result.totalSpent} sats") InfoRow("Fee", "${result.fee} sats") - InfoRow("Fee Rate", "${result.feePerByte} sat/vB") - InfoRow("Size", "${result.bytes} bytes") - InfoRow("Inputs", "${result.inputs.size}") - InfoRow("Outputs", "${result.outputs.size}") + InfoRow("Fee Rate", "${result.feeRate} sat/vB") } - if (result.inputs.isNotEmpty()) { - VerticalSpacer(8.dp) - Caption13Up( - text = "Inputs (${result.inputs.size})", - color = Colors.White64, - ) - VerticalSpacer(4.dp) - result.inputs.forEach { input -> - ResultCard { - Caption( - text = "${input.txid.take(8)}...${input.txid.takeLast(8)}:${input.vout}", - color = Colors.Brand, - ) - InfoRow("Amount", "${input.amount} sats") - InfoRow("Path", input.path) - } - VerticalSpacer(4.dp) - } - } + VerticalSpacer(8.dp) + Caption13Up( + text = "PSBT", + color = Colors.White64, + ) + VerticalSpacer(4.dp) - if (result.outputs.isNotEmpty()) { - VerticalSpacer(8.dp) - Caption13Up( - text = "Outputs (${result.outputs.size})", - color = Colors.White64, - ) - VerticalSpacer(4.dp) - result.outputs.forEach { output -> - OutputCard(output) - VerticalSpacer(4.dp) + ResultCard { + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth(), + ) { + Caption( + text = result.psbt, + color = Colors.White, + modifier = Modifier.weight(1f), + ) + HorizontalSpacer(8.dp) + Icon( + painter = painterResource(R.drawable.ic_copy), + contentDescription = "Copy PSBT", + tint = Colors.Brand, + modifier = Modifier + .size(20.dp) + .clickableAlpha(onClick = onCopyPsbt), + ) } } @@ -284,35 +278,6 @@ private fun ReviewSection( } } -@Composable -private fun OutputCard(output: TrezorPrecomposedOutput) { - ResultCard { - when (output) { - is TrezorPrecomposedOutput.Payment -> { - InfoRow("Type", "Payment") - Caption( - text = output.address, - color = Colors.Brand, - ) - InfoRow("Amount", "${output.amount} sats") - } - is TrezorPrecomposedOutput.Change -> { - InfoRow("Type", "Change") - Caption( - text = output.address, - color = Colors.White64, - ) - InfoRow("Amount", "${output.amount} sats") - InfoRow("Path", output.path) - } - is TrezorPrecomposedOutput.OpReturn -> { - InfoRow("Type", "OP_RETURN") - InfoRow("Data", output.dataHex) - } - } - } -} - @Composable private fun SignedResultSection( signedTx: TrezorSignedTx, @@ -424,7 +389,7 @@ private fun PreviewSendForm() { onAmountChange = {}, onFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, @@ -449,7 +414,7 @@ private fun PreviewSendFormFilled() { onAmountChange = {}, onFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, @@ -470,7 +435,7 @@ private fun PreviewSendReview() { onAmountChange = {}, onFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, @@ -491,7 +456,7 @@ private fun PreviewSendSigned() { onAmountChange = {}, onFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, @@ -512,7 +477,7 @@ private fun PreviewSendBroadcast() { onAmountChange = {}, onFeeRateChange = {}, onToggleSendMax = {}, - onSortingStrategyChange = {}, + onCoinSelectionChange = {}, onCompose = {}, onSign = {}, onBroadcast = {}, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt index 49e981c7b..c60e5090a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorPreviewData.kt @@ -5,15 +5,12 @@ import com.synonym.bitkitcore.AccountInfoResult import com.synonym.bitkitcore.AccountType import com.synonym.bitkitcore.AccountUtxo import com.synonym.bitkitcore.ComposeAccount +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.SingleAddressInfoResult import com.synonym.bitkitcore.TrezorAddressResponse import com.synonym.bitkitcore.TrezorDeviceInfo import com.synonym.bitkitcore.TrezorFeatures -import com.synonym.bitkitcore.TrezorPrecomposedInput -import com.synonym.bitkitcore.TrezorPrecomposedOutput -import com.synonym.bitkitcore.TrezorPrecomposedResult import com.synonym.bitkitcore.TrezorPublicKeyResponse -import com.synonym.bitkitcore.TrezorScriptType import com.synonym.bitkitcore.TrezorSignedTx import com.synonym.bitkitcore.TrezorTransportType import to.bitkit.repositories.KnownDevice @@ -166,34 +163,11 @@ internal object TrezorPreviewData { blockHeight = 850_000u, ) - private val samplePrecomposedInput = TrezorPrecomposedInput( - txid = SAMPLE_TXID, - vout = 0u, - amount = "50000", - address = SAMPLE_ADDRESS, - path = "m/84'/0'/0'/0/0", - scriptType = TrezorScriptType.SPEND_WITNESS, - ) - - val samplePrecomposedResult = TrezorPrecomposedResult.Final( - totalSpent = "51000", - fee = "1000", - feePerByte = "5.0", - bytes = 200u, - inputs = listOf(samplePrecomposedInput), - outputs = listOf( - TrezorPrecomposedOutput.Payment( - address = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", - amount = "45000", - ), - TrezorPrecomposedOutput.Change( - address = "bc1q9h5yjqka5pv0arpmc2c2fqhx6l2eme72sxlfkn", - path = "m/84'/0'/0'/1/0", - amount = "4000", - scriptType = TrezorScriptType.SPEND_WITNESS, - ), - ), - outputsPermutation = listOf(0u, 1u), + val sampleComposeResult = ComposeResult.Success( + psbt = "cHNidC8BAH0CAAAA...", + fee = 1000uL, + feeRate = 5.0f, + totalSpent = 51000uL, ) val sampleSignedTx = TrezorSignedTx( @@ -240,7 +214,7 @@ internal object TrezorPreviewData { sendAddress = "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", sendAmountSats = "45000", sendFeeRate = "5", - precomposedResult = samplePrecomposedResult, + composeResult = sampleComposeResult, ) val uiStateSigned = TrezorUiState( diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt index 890233e23..d68a6e477 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorScreen.kt @@ -44,7 +44,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.synonym.bitkitcore.TrezorSortingStrategy +import com.synonym.bitkitcore.CoinSelection import to.bitkit.R import to.bitkit.repositories.KnownDevice import to.bitkit.repositories.TrezorState @@ -145,7 +145,7 @@ private fun TrezorScreenContent( onSendAmountChange = viewModel::setSendAmount, onSendFeeRateChange = viewModel::setSendFeeRate, onToggleSendMax = viewModel::toggleSendMax, - onSortingStrategyChange = viewModel::setSortingStrategy, + onCoinSelectionChange = viewModel::setCoinSelection, onCompose = viewModel::composeTx, onSign = viewModel::signComposedTx, onBroadcast = viewModel::broadcastSignedTx, @@ -181,7 +181,7 @@ private fun Content( onSendAmountChange: (String) -> Unit = {}, onSendFeeRateChange: (String) -> Unit = {}, onToggleSendMax: () -> Unit = {}, - onSortingStrategyChange: (TrezorSortingStrategy) -> Unit = {}, + onCoinSelectionChange: (CoinSelection) -> Unit = {}, onCompose: () -> Unit = {}, onSign: () -> Unit = {}, onBroadcast: () -> Unit = {}, @@ -383,7 +383,7 @@ private fun Content( onSendAmountChange = onSendAmountChange, onSendFeeRateChange = onSendFeeRateChange, onToggleSendMax = onToggleSendMax, - onSortingStrategyChange = onSortingStrategyChange, + onCoinSelectionChange = onCoinSelectionChange, onCompose = onCompose, onSign = onSign, onBroadcast = onBroadcast, diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 2c4eda3e9..96af95b19 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -3,19 +3,12 @@ package to.bitkit.ui.screens.trezor import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.AccountInfoResult +import com.synonym.bitkitcore.CoinSelection +import com.synonym.bitkitcore.ComposeOutput +import com.synonym.bitkitcore.ComposeResult import com.synonym.bitkitcore.SingleAddressInfoResult -import com.synonym.bitkitcore.TrezorCoinType -import com.synonym.bitkitcore.TrezorFeeLevel -import com.synonym.bitkitcore.TrezorPrecomposeOutput -import com.synonym.bitkitcore.TrezorPrecomposeParams -import com.synonym.bitkitcore.TrezorPrecomposedOutput -import com.synonym.bitkitcore.TrezorPrecomposedResult import com.synonym.bitkitcore.TrezorScriptType -import com.synonym.bitkitcore.TrezorSignTxParams import com.synonym.bitkitcore.TrezorSignedTx -import com.synonym.bitkitcore.TrezorSortingStrategy -import com.synonym.bitkitcore.TrezorTxInput -import com.synonym.bitkitcore.TrezorTxOutput import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow @@ -233,10 +226,10 @@ class TrezorViewModel @Inject constructor( isSendMax = false, isComposing = false, isSigning = false, - precomposedResult = null, + composeResult = null, signedTxResult = null, sendStep = SendStep.FORM, - sortingStrategy = TrezorSortingStrategy.BIP69, + coinSelection = CoinSelection.BRANCH_AND_BOUND, isBroadcasting = false, broadcastTxid = null, ) @@ -351,8 +344,8 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(isSendMax = !it.isSendMax) } } - fun setSortingStrategy(strategy: TrezorSortingStrategy) { - _uiState.update { it.copy(sortingStrategy = strategy) } + fun setCoinSelection(selection: CoinSelection) { + _uiState.update { it.copy(coinSelection = selection) } } fun broadcastSignedTx() { @@ -383,10 +376,10 @@ class TrezorViewModel @Inject constructor( isSendMax = false, isComposing = false, isSigning = false, - precomposedResult = null, + composeResult = null, signedTxResult = null, sendStep = SendStep.FORM, - sortingStrategy = TrezorSortingStrategy.BIP69, + coinSelection = CoinSelection.BRANCH_AND_BOUND, isBroadcasting = false, broadcastTxid = null, ) @@ -397,7 +390,7 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy( sendStep = SendStep.FORM, - precomposedResult = null, + composeResult = null, signedTxResult = null, ) } @@ -411,33 +404,35 @@ class TrezorViewModel @Inject constructor( _uiState.update { it.copy(isComposing = true) } - val coin = state.selectedNetwork.toTrezorCoinType() + val feeRate = state.sendFeeRate.toFloatOrNull() ?: return@launch TrezorDebugLog.log("COMPOSE", "=== composeTx START ===") TrezorDebugLog.log("COMPOSE", "address=${state.sendAddress}") TrezorDebugLog.log("COMPOSE", "amount=${state.sendAmountSats}, sendMax=${state.isSendMax}") - TrezorDebugLog.log("COMPOSE", "feeRate=${state.sendFeeRate} sat/vB, coin=$coin") - TrezorDebugLog.log("COMPOSE", "account.path=${accountInfo.account.path}") - TrezorDebugLog.log("COMPOSE", "utxos=${accountInfo.account.utxo.size}, balance=${accountInfo.balance}") + TrezorDebugLog.log("COMPOSE", "feeRate=$feeRate sat/vB, network=${state.selectedNetwork}") + TrezorDebugLog.log("COMPOSE", "coinSelection=${state.coinSelection}") + TrezorDebugLog.log("COMPOSE", "balance=${accountInfo.balance}") val output = if (state.isSendMax) { - TrezorPrecomposeOutput.SendMax(address = state.sendAddress) + ComposeOutput.SendMax(address = state.sendAddress) } else { - TrezorPrecomposeOutput.Payment(address = state.sendAddress, amount = state.sendAmountSats) + val amountSats = state.sendAmountSats.toULongOrNull() + if (amountSats == null) { + _uiState.update { it.copy(isComposing = false) } + ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter a valid amount") + return@launch + } + ComposeOutput.Payment(address = state.sendAddress, amountSats = amountSats) } - val params = TrezorPrecomposeParams( + trezorRepo.composeTransaction( + extendedKey = state.lookupInput.trim(), outputs = listOf(output), - coin = coin, - account = accountInfo.account, - feeLevels = listOf( - TrezorFeeLevel(feePerUnit = state.sendFeeRate, baseFee = null, floorBaseFee = null) - ), - sequence = null, - sortingStrategy = state.sortingStrategy, + feeRates = listOf(feeRate), + network = state.selectedNetwork, + accountType = accountInfo.accountType, + coinSelection = state.coinSelection, ) - - trezorRepo.precomposeTransaction(params) - .onSuccess { handlePrecomposeResults(it) } + .onSuccess { handleComposeResults(it) } .onFailure { TrezorDebugLog.log("COMPOSE", "FAILED: ${it.message}") _uiState.update { it.copy(isComposing = false) } @@ -455,61 +450,35 @@ class TrezorViewModel @Inject constructor( ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter an amount") return false } - val feeRate = state.sendFeeRate.toLongOrNull() - if (feeRate == null || feeRate <= 0) { + val feeRate = state.sendFeeRate.toFloatOrNull() + if (feeRate == null || feeRate <= 0f) { ToastEventBus.send(type = Toast.ToastType.ERROR, title = "Enter a valid fee rate") return false } return true } - private suspend fun handlePrecomposeResults(results: List) { + private suspend fun handleComposeResults(results: List) { TrezorDebugLog.log("COMPOSE", "Got ${results.size} result(s)") results.forEachIndexed { i, r -> when (r) { - is TrezorPrecomposedResult.Final -> TrezorDebugLog.log( + is ComposeResult.Success -> TrezorDebugLog.log( "COMPOSE", - "[$i] Final: fee=${r.fee}, totalSpent=${r.totalSpent}, " + - "feePerByte=${r.feePerByte}, bytes=${r.bytes}, " + - "inputs=${r.inputs.size}, outputs=${r.outputs.size}" + "[$i] Success: fee=${r.fee}, totalSpent=${r.totalSpent}, " + + "feeRate=${r.feeRate}" ) - is TrezorPrecomposedResult.NonFinal -> TrezorDebugLog.log( - "COMPOSE", - "[$i] NonFinal: max=${r.max}, fee=${r.fee}" - ) - is TrezorPrecomposedResult.Error -> TrezorDebugLog.log( + is ComposeResult.Error -> TrezorDebugLog.log( "COMPOSE", "[$i] Error: ${r.error}" ) } } - val finalResult = results.filterIsInstance().firstOrNull() - val errorResult = results.filterIsInstance().firstOrNull() - if (finalResult != null) { - finalResult.inputs.forEach { - TrezorDebugLog.log( - "COMPOSE", - " input: txid=${it.txid}, vout=${it.vout}, " + - "amount=${it.amount}, path=${it.path}, scriptType=${it.scriptType}" - ) - } - finalResult.outputs.forEach { - when (it) { - is TrezorPrecomposedOutput.Payment -> - TrezorDebugLog.log("COMPOSE", " output(payment): addr=${it.address}, amount=${it.amount}") - is TrezorPrecomposedOutput.Change -> - TrezorDebugLog.log( - "COMPOSE", - " output(change): addr=${it.address}, " + - "amount=${it.amount}, path=${it.path}" - ) - is TrezorPrecomposedOutput.OpReturn -> - TrezorDebugLog.log("COMPOSE", " output(opreturn): ${it.dataHex}") - } - } + val successResult = results.filterIsInstance().firstOrNull() + val errorResult = results.filterIsInstance().firstOrNull() + if (successResult != null) { TrezorDebugLog.log("COMPOSE", "=== composeTx SUCCESS ===") _uiState.update { - it.copy(isComposing = false, precomposedResult = finalResult, sendStep = SendStep.REVIEW) + it.copy(isComposing = false, composeResult = successResult, sendStep = SendStep.REVIEW) } ToastEventBus.send(type = Toast.ToastType.INFO, title = "Transaction composed") } else if (errorResult != null) { @@ -526,82 +495,41 @@ class TrezorViewModel @Inject constructor( fun signComposedTx() { viewModelScope.launch(bgDispatcher) { val state = _uiState.value - val result = state.precomposedResult ?: return@launch + val result = state.composeResult ?: return@launch TrezorDebugLog.log("SIGN", "=== signComposedTx START ===") - TrezorDebugLog.log("SIGN", "inputs=${result.inputs.size}, outputs=${result.outputs.size}") TrezorDebugLog.log("SIGN", "network=${state.selectedNetwork}") - result.inputs.forEach { - TrezorDebugLog.log( - "SIGN", - " input: txid=${it.txid}, vout=${it.vout}, " + - "amount=${it.amount}, scriptType=${it.scriptType}, path=${it.path}" - ) - } + TrezorDebugLog.log("SIGN", "psbt length=${result.psbt.length}") _uiState.update { it.copy(isSigning = true) } - TrezorDebugLog.log("SIGN", "Converting precomposed to sign params...") - trezorRepo.convertToSignParams( - inputs = result.inputs, - outputs = result.outputs, - coin = state.selectedNetwork.toTrezorCoinType(), - ).onSuccess { logAndSign(it) } + trezorRepo.signTxFromPsbt( + psbtBase64 = result.psbt, + network = state.selectedNetwork.toTrezorCoinType(), + ) + .onSuccess { signedTx -> + TrezorDebugLog.log("SIGN", "=== signComposedTx SUCCESS ===") + TrezorDebugLog.log( + "SIGN", + "signatures=${signedTx.signatures.size}, " + + "txid=${signedTx.txid}, rawTxLen=${signedTx.serializedTx.length}" + ) + _uiState.update { + it.copy(isSigning = false, signedTxResult = signedTx, sendStep = SendStep.SIGNED) + } + ToastEventBus.send( + type = Toast.ToastType.SUCCESS, + title = "Transaction signed (${signedTx.signatures.size} inputs)" + ) + } .onFailure { - TrezorDebugLog.log("SIGN", "convertToSignParams FAILED: ${it.message}") + TrezorDebugLog.log("SIGN", "signTxFromPsbt FAILED: ${it.message}") _uiState.update { s -> s.copy(isSigning = false) } ToastEventBus.send(it) } } } - private suspend fun logAndSign(signParams: TrezorSignTxParams) { - val network = _uiState.value.selectedNetwork.toTrezorCoinType() - val txids = signParams.inputs.map { it.prevHash }.distinct() - TrezorDebugLog.log( - "SIGN", - "Sign params: inputs=${signParams.inputs.size}, " + - "outputs=${signParams.outputs.size}, coin=${signParams.coin}" - ) - TrezorDebugLog.log("SIGN", "Fetching ${txids.size} prev tx(s) from Electrum...") - trezorRepo.fetchPrevTxs(txids = txids, network = network) - .onSuccess { prevTxs -> - TrezorDebugLog.log("SIGN", "Fetched ${prevTxs.size} prev tx(s)") - val completeParams = signParams.copy(prevTxs = prevTxs) - TrezorDebugLog.log("SIGN", "Calling trezor signTx...") - executeSign(completeParams) - } - .onFailure { - TrezorDebugLog.log("SIGN", "fetchPrevTxs FAILED: ${it.message}") - _uiState.update { s -> s.copy(isSigning = false) } - ToastEventBus.send(it) - } - } - - private suspend fun executeSign(params: TrezorSignTxParams) { - trezorRepo.signTxWithParams(params) - .onSuccess { signedTx -> - TrezorDebugLog.log("SIGN", "=== signComposedTx SUCCESS ===") - TrezorDebugLog.log( - "SIGN", - "signatures=${signedTx.signatures.size}, " + - "txid=${signedTx.txid}, rawTxLen=${signedTx.serializedTx.length}" - ) - _uiState.update { - it.copy(isSigning = false, signedTxResult = signedTx, sendStep = SendStep.SIGNED) - } - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = "Transaction signed (${signedTx.signatures.size} inputs)" - ) - } - .onFailure { - TrezorDebugLog.log("SIGN", "signTx FAILED: ${it.message}") - _uiState.update { s -> s.copy(isSigning = false) } - ToastEventBus.send(it) - } - } - fun clearError() { trezorRepo.clearError() } @@ -620,28 +548,6 @@ class TrezorViewModel @Inject constructor( trezorRepo.cancelPairingCode() } - /** - * Sign a Bitcoin transaction. - */ - fun signTx( - inputs: List, - outputs: List, - coin: TrezorCoinType = TrezorCoinType.BITCOIN, - lockTime: UInt? = null, - version: UInt? = null, - ) { - viewModelScope.launch(bgDispatcher) { - trezorRepo.signTx(inputs, outputs, coin, lockTime, version) - .onSuccess { signedTx -> - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = "Transaction signed (${signedTx.signatures.size} inputs)" - ) - } - .onFailure { ToastEventBus.send(it) } - } - } - /** * Clear stored pairing credentials for a device. */ @@ -678,10 +584,10 @@ data class TrezorUiState( val isSendMax: Boolean = false, val isComposing: Boolean = false, val isSigning: Boolean = false, - val precomposedResult: TrezorPrecomposedResult.Final? = null, + val composeResult: ComposeResult.Success? = null, val signedTxResult: TrezorSignedTx? = null, val sendStep: SendStep = SendStep.FORM, - val sortingStrategy: TrezorSortingStrategy = TrezorSortingStrategy.BIP69, + val coinSelection: CoinSelection = CoinSelection.BRANCH_AND_BOUND, val isBroadcasting: Boolean = false, val broadcastTxid: String? = null, ) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt new file mode 100644 index 000000000..35048a0d5 --- /dev/null +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -0,0 +1,403 @@ +package to.bitkit.repositories + +import android.content.Context +import android.content.SharedPreferences +import com.synonym.bitkitcore.TrezorAddressResponse +import com.synonym.bitkitcore.TrezorDeviceInfo +import com.synonym.bitkitcore.TrezorFeatures +import com.synonym.bitkitcore.TrezorPublicKeyResponse +import com.synonym.bitkitcore.TrezorSignedMessageResponse +import com.synonym.bitkitcore.TrezorTransportType +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import to.bitkit.env.Env +import to.bitkit.services.TrezorService +import to.bitkit.services.TrezorTransport +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TrezorRepoTest : BaseUnitTest() { + + companion object Fixtures { + private const val DEVICE_ID = "device-123" + private const val DEVICE_NAME = "Trezor Model T" + private const val DEVICE_PATH = "/dev/trezor0" + private const val DEVICE_LABEL = "My Trezor" + private const val DEVICE_MODEL = "T" + private const val TEST_MESSAGE = "Hello Trezor" + private const val TEST_SIGNATURE = "signature123" + private const val TEST_ADDRESS = "bc1qtest" + } + + @get:Rule(order = 1) + val tempFolder = TemporaryFolder() + + private val context = mock() + private val trezorService = mock() + private val trezorTransport = mock() + private val prefs = mock() + private val prefsEditor = mock() + + private lateinit var sut: TrezorRepo + + @Before + fun setUp() { + Env.initAppStoragePath(tempFolder.root.absolutePath) + whenever(context.getSharedPreferences(any(), any())).thenReturn(prefs) + whenever(prefs.getString(any(), anyOrNull())).thenReturn(null) + whenever(prefs.edit()).thenReturn(prefsEditor) + whenever(prefsEditor.putString(any(), any())).thenReturn(prefsEditor) + whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(false)) + whenever(trezorTransport.externalDisconnect).thenReturn(MutableSharedFlow()) + whenever(context.filesDir).thenReturn(tempFolder.root) + } + + private fun createSut(): TrezorRepo = TrezorRepo( + context = context, + trezorService = trezorService, + trezorTransport = trezorTransport, + bgDispatcher = testDispatcher, + ) + + @Suppress("LongParameterList") + private fun mockDeviceInfo( + id: String = DEVICE_ID, + transportType: TrezorTransportType = TrezorTransportType.USB, + name: String? = DEVICE_NAME, + path: String = DEVICE_PATH, + label: String? = DEVICE_LABEL, + model: String? = DEVICE_MODEL, + isBootloader: Boolean = false, + ) = TrezorDeviceInfo( + id = id, + transportType = transportType, + name = name, + path = path, + label = label, + model = model, + isBootloader = isBootloader, + ) + + private fun mockFeatures( + label: String? = DEVICE_LABEL, + model: String? = DEVICE_MODEL, + ): TrezorFeatures = mock { + on { this.label }.thenReturn(label) + on { this.model }.thenReturn(model) + } + + // region initialize + + @Test + fun `initialize should update state to initialized on success`() = test { + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isSuccess) + assertTrue(sut.state.value.isInitialized) + assertNull(sut.state.value.error) + } + + @Test + fun `initialize should set error on failure`() = test { + whenever(trezorService.initialize(anyOrNull())).thenThrow(RuntimeException("init failed")) + sut = createSut() + + val result = sut.initialize() + + assertTrue(result.isFailure) + assertFalse(sut.state.value.isInitialized) + assertEquals("init failed", sut.state.value.error) + } + + // endregion + + // region scan + + @Test + fun `scan should return devices and update nearbyDevices state`() = test { + val devices = listOf(mockDeviceInfo()) + whenever(trezorService.scan()).thenReturn(devices) + sut = createSut() + + val result = sut.scan() + + assertTrue(result.isSuccess) + assertEquals(devices, result.getOrNull()) + assertEquals(devices, sut.state.value.nearbyDevices) + assertFalse(sut.state.value.isScanning) + } + + @Test + fun `scan should set error on failure`() = test { + whenever(trezorService.scan()).thenThrow(RuntimeException("scan failed")) + sut = createSut() + + val result = sut.scan() + + assertTrue(result.isFailure) + assertFalse(sut.state.value.isScanning) + assertEquals("scan failed", sut.state.value.error) + } + + // endregion + + // region connect + + @Test + fun `connect should return features and update connectedDevice state`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorService.connect(DEVICE_ID)).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + // First scan to populate nearbyDevices + sut.scan() + + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isSuccess) + assertEquals(features, result.getOrNull()) + assertEquals(features, sut.state.value.connectedDevice) + assertEquals(DEVICE_ID, sut.state.value.connectedDeviceId) + assertFalse(sut.state.value.isConnecting) + } + + @Test + fun `connect should set error on failure`() = test { + whenever(trezorService.connect(DEVICE_ID)).thenThrow(RuntimeException("connect failed")) + sut = createSut() + + val result = sut.connect(DEVICE_ID) + + assertTrue(result.isFailure) + assertFalse(sut.state.value.isConnecting) + assertEquals("connect failed", sut.state.value.error) + } + + // endregion + + // region disconnect + + @Test + fun `disconnect should clear connectedDevice state`() = test { + val features = mockFeatures() + val device = mockDeviceInfo() + whenever(trezorService.connect(DEVICE_ID)).thenReturn(features) + whenever(trezorService.scan()).thenReturn(listOf(device)) + sut = createSut() + + sut.scan() + sut.connect(DEVICE_ID) + assertEquals(features, sut.state.value.connectedDevice) + + val result = sut.disconnect() + + assertTrue(result.isSuccess) + assertNull(sut.state.value.connectedDevice) + assertNull(sut.state.value.connectedDeviceId) + assertNull(sut.state.value.lastAddress) + assertNull(sut.state.value.lastPublicKey) + } + + // endregion + + // region getAddress + + @Test + fun `getAddress should return address and update lastAddress`() = test { + val addressResponse = mock() + whenever(trezorService.isConnected()).thenReturn(true) + whenever( + trezorService.getAddress( + path = any(), + coin = any(), + showOnTrezor = any(), + scriptType = anyOrNull(), + ) + ).thenReturn(addressResponse) + sut = createSut() + + val result = sut.getAddress() + + assertTrue(result.isSuccess) + assertEquals(addressResponse, result.getOrNull()) + assertEquals(addressResponse, sut.state.value.lastAddress) + assertNull(sut.state.value.error) + } + + @Test + fun `getAddress should set error on failure`() = test { + whenever(trezorService.isConnected()).thenReturn(false) + whenever(trezorService.scan()).thenReturn(emptyList()) + sut = createSut() + + val result = sut.getAddress() + + assertTrue(result.isFailure) + assertNotNull(sut.state.value.error) + } + + // endregion + + // region getPublicKey + + @Test + fun `getPublicKey should return public key and update lastPublicKey`() = test { + val publicKeyResponse = mock() + whenever(trezorService.isConnected()).thenReturn(true) + whenever( + trezorService.getPublicKey( + path = any(), + coin = any(), + showOnTrezor = any(), + ) + ).thenReturn(publicKeyResponse) + sut = createSut() + + val result = sut.getPublicKey() + + assertTrue(result.isSuccess) + assertEquals(publicKeyResponse, result.getOrNull()) + assertEquals(publicKeyResponse, sut.state.value.lastPublicKey) + assertNull(sut.state.value.error) + } + + // endregion + + // region signMessage + + @Test + fun `signMessage should return signed message on success`() = test { + val signedResponse = mock { + on { signature }.thenReturn(TEST_SIGNATURE) + on { address }.thenReturn(TEST_ADDRESS) + } + whenever(trezorService.isConnected()).thenReturn(true) + whenever( + trezorService.signMessage( + path = any(), + message = any(), + coin = any(), + ) + ).thenReturn(signedResponse) + sut = createSut() + + val result = sut.signMessage(message = TEST_MESSAGE) + + assertTrue(result.isSuccess) + assertEquals(signedResponse, result.getOrNull()) + assertNull(sut.state.value.error) + } + + // endregion + + // region verifyMessage + + @Test + fun `verifyMessage should return true for valid signature`() = test { + whenever(trezorService.isConnected()).thenReturn(true) + whenever( + trezorService.verifyMessage( + address = any(), + signature = any(), + message = any(), + coin = any(), + ) + ).thenReturn(true) + sut = createSut() + + val result = sut.verifyMessage( + address = TEST_ADDRESS, + signature = TEST_SIGNATURE, + message = TEST_MESSAGE, + ) + + assertTrue(result.isSuccess) + assertTrue(result.getOrNull()!!) + assertNull(sut.state.value.error) + } + + // endregion + + // region hasKnownDevices + + @Test + fun `hasKnownDevices should return false when no known devices`() { + sut = createSut() + + assertFalse(sut.hasKnownDevices()) + } + + // endregion + + // region clearError + + @Test + fun `clearError should set error to null`() = test { + whenever(trezorService.scan()).thenThrow(RuntimeException("some error")) + sut = createSut() + + sut.scan() + assertNotNull(sut.state.value.error) + + sut.clearError() + + assertNull(sut.state.value.error) + } + + // endregion + + // region listDevices + + @Test + fun `listDevices should return devices and update nearbyDevices state`() = test { + val devices = listOf(mockDeviceInfo()) + whenever(trezorService.listDevices()).thenReturn(devices) + sut = createSut() + + val result = sut.listDevices() + + assertTrue(result.isSuccess) + assertEquals(devices, result.getOrNull()) + assertEquals(devices, sut.state.value.nearbyDevices) + } + + // endregion + + // region initial state + + @Test + fun `initial state should have default values`() { + sut = createSut() + + val state = sut.state.value + assertFalse(state.isInitialized) + assertFalse(state.isScanning) + assertFalse(state.isConnecting) + assertFalse(state.isAutoReconnecting) + assertTrue(state.knownDevices.isEmpty()) + assertTrue(state.nearbyDevices.isEmpty()) + assertNull(state.connectedDevice) + assertNull(state.connectedDeviceId) + assertNull(state.lastAddress) + assertNull(state.lastPublicKey) + assertNull(state.error) + } + + // endregion +} diff --git a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt index a0cb75ab6..1c6a2f572 100644 --- a/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/trezor/TrezorViewModelTest.kt @@ -2,11 +2,13 @@ package to.bitkit.ui.screens.trezor import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle import org.junit.Before import org.junit.Test +import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import to.bitkit.repositories.TrezorRepo import to.bitkit.repositories.TrezorState @@ -20,255 +22,261 @@ import com.synonym.bitkitcore.Network as BitkitCoreNetwork @OptIn(ExperimentalCoroutinesApi::class) class TrezorViewModelTest : BaseUnitTest() { - private val trezorRepo = mock() + private val trezorRepo: TrezorRepo = mock() + private val trezorStateFlow = MutableStateFlow(TrezorState()) + private val needsPairingCodeFlow = MutableStateFlow(false) - private lateinit var viewModel: TrezorViewModel + private lateinit var sut: TrezorViewModel @Before - fun setup() = runBlocking { - val stateFlow = MutableStateFlow(TrezorState()) - whenever(trezorRepo.state).thenReturn(stateFlow.asStateFlow()) - whenever(trezorRepo.needsPairingCode).thenReturn(MutableStateFlow(false).asStateFlow()) - - viewModel = TrezorViewModel( - bgDispatcher = testDispatcher, - trezorRepo = trezorRepo, - ) + fun setUp() { + whenever(trezorRepo.state).thenReturn(trezorStateFlow) + whenever(trezorRepo.needsPairingCode).thenReturn(needsPairingCodeFlow) + whenever(trezorRepo.observeExternalDisconnects(any())).then { } + sut = createViewModel() } - // region Initial State + // region Pure state setters @Test - fun `initial uiState should have default derivation path`() { - val state = viewModel.uiState.value + fun `setDerivationPath should update uiState derivationPath`() { + val path = "m/49'/0'/0'/0/0" + sut.setDerivationPath(path) - assertTrue(state.derivationPath.startsWith("m/84'/")) - assertTrue(state.derivationPath.endsWith("/0/0")) + assertEquals(path, sut.uiState.value.derivationPath) } @Test - fun `initial uiState should have addressIndex 0`() { - val state = viewModel.uiState.value + fun `setSelectedNetwork to BITCOIN should use coinType 0 in path`() { + sut.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + val state = sut.uiState.value + assertEquals(BitkitCoreNetwork.BITCOIN, state.selectedNetwork) + assertEquals("m/84'/0'/0'/0/0", state.derivationPath) assertEquals(0, state.addressIndex) } @Test - fun `initial uiState should not be in any loading state`() { - val state = viewModel.uiState.value - - assertFalse(state.isGettingAddress) - assertFalse(state.isGettingPublicKey) - assertFalse(state.isSigningMessage) - assertFalse(state.isVerifyingMessage) - assertFalse(state.isLookingUp) - assertFalse(state.isComposing) - assertFalse(state.isSigning) - assertFalse(state.isBroadcasting) + fun `setSelectedNetwork to TESTNET should use coinType 1 in path`() { + sut.setSelectedNetwork(BitkitCoreNetwork.TESTNET) + + val state = sut.uiState.value + assertEquals(BitkitCoreNetwork.TESTNET, state.selectedNetwork) + assertEquals("m/84'/1'/0'/0/0", state.derivationPath) + assertEquals(0, state.addressIndex) } @Test - fun `initial uiState should have empty lookup input`() { - val state = viewModel.uiState.value + fun `incrementAddressIndex should increment index and update path`() { + sut.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + sut.incrementAddressIndex() - assertEquals("", state.lookupInput) + val state = sut.uiState.value + assertEquals(1, state.addressIndex) + assertEquals("m/84'/0'/0'/0/1", state.derivationPath) } @Test - fun `initial uiState should have no results`() { - val state = viewModel.uiState.value - - assertNull(state.accountInfoResult) - assertNull(state.addressInfoResult) - assertNull(state.lastSignature) - assertNull(state.lastSigningAddress) - assertNull(state.precomposedResult) - assertNull(state.signedTxResult) - assertNull(state.broadcastTxid) + fun `setMessageToSign should update messageToSign`() { + val message = "Test message" + sut.setMessageToSign(message) + + assertEquals(message, sut.uiState.value.messageToSign) } @Test - fun `initial uiState should have FORM send step`() { - val state = viewModel.uiState.value + fun `setSendAddress should update sendAddress`() { + val address = "bc1qtest123" + sut.setSendAddress(address) - assertEquals(SendStep.FORM, state.sendStep) + assertEquals(address, sut.uiState.value.sendAddress) } - // endregion + @Test + fun `setSendAmount should update sendAmountSats`() { + val amount = "50000" + sut.setSendAmount(amount) - // region Network Selection + assertEquals(amount, sut.uiState.value.sendAmountSats) + } @Test - fun `setSelectedNetwork to BITCOIN should update coin type to 0`() { - viewModel.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + fun `setSendFeeRate should update sendFeeRate`() { + val feeRate = "10" + sut.setSendFeeRate(feeRate) - val state = viewModel.uiState.value - assertEquals(BitkitCoreNetwork.BITCOIN, state.selectedNetwork) - assertEquals("m/84'/0'/0'/0/0", state.derivationPath) + assertEquals(feeRate, sut.uiState.value.sendFeeRate) } @Test - fun `setSelectedNetwork to TESTNET should update coin type to 1`() { - viewModel.setSelectedNetwork(BitkitCoreNetwork.TESTNET) + fun `toggleSendMax should toggle isSendMax`() { + assertFalse(sut.uiState.value.isSendMax) - val state = viewModel.uiState.value - assertEquals(BitkitCoreNetwork.TESTNET, state.selectedNetwork) - assertEquals("m/84'/1'/0'/0/0", state.derivationPath) + sut.toggleSendMax() + assertTrue(sut.uiState.value.isSendMax) + + sut.toggleSendMax() + assertFalse(sut.uiState.value.isSendMax) } @Test - fun `setSelectedNetwork to REGTEST should update coin type to 1`() { - viewModel.setSelectedNetwork(BitkitCoreNetwork.REGTEST) + fun `resetSendFlow should clear all send-related state`() { + sut.setSendAddress("bc1qtest") + sut.setSendAmount("10000") + sut.setSendFeeRate("5") + sut.toggleSendMax() - val state = viewModel.uiState.value - assertEquals(BitkitCoreNetwork.REGTEST, state.selectedNetwork) - assertEquals("m/84'/1'/0'/0/0", state.derivationPath) + sut.resetSendFlow() + + val state = sut.uiState.value + assertEquals("", state.sendAddress) + assertEquals("", state.sendAmountSats) + assertEquals("2", state.sendFeeRate) + assertFalse(state.isSendMax) + assertFalse(state.isComposing) + assertFalse(state.isSigning) + assertNull(state.composeResult) + assertNull(state.signedTxResult) + assertEquals(SendStep.FORM, state.sendStep) + assertFalse(state.isBroadcasting) + assertNull(state.broadcastTxid) } @Test - fun `setSelectedNetwork should reset addressIndex to 0`() { - viewModel.incrementAddressIndex() - viewModel.incrementAddressIndex() - assertEquals(2, viewModel.uiState.value.addressIndex) - - viewModel.setSelectedNetwork(BitkitCoreNetwork.TESTNET) + fun `setLookupInput should update lookupInput`() { + val input = "xpub6test123" + sut.setLookupInput(input) - assertEquals(0, viewModel.uiState.value.addressIndex) + assertEquals(input, sut.uiState.value.lookupInput) } // endregion - // region Address Index + // region Async methods @Test - fun `incrementAddressIndex should increment index and update path`() { - viewModel.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + fun `initialize should call trezorRepo initialize`() = test { + whenever(trezorRepo.initialize()).thenReturn(Result.success(Unit)) - viewModel.incrementAddressIndex() + sut.initialize() + advanceUntilIdle() - val state = viewModel.uiState.value - assertEquals(1, state.addressIndex) - assertEquals("m/84'/0'/0'/0/1", state.derivationPath) + verify(trezorRepo).initialize() } @Test - fun `incrementAddressIndex multiple times should increment correctly`() { - viewModel.setSelectedNetwork(BitkitCoreNetwork.BITCOIN) + fun `scan should call trezorRepo scan`() = test { + whenever(trezorRepo.scan()).thenReturn(Result.success(emptyList())) - viewModel.incrementAddressIndex() - viewModel.incrementAddressIndex() - viewModel.incrementAddressIndex() + sut.scan() + advanceUntilIdle() - val state = viewModel.uiState.value - assertEquals(3, state.addressIndex) - assertEquals("m/84'/0'/0'/0/3", state.derivationPath) + verify(trezorRepo).scan() } - // endregion - - // region Derivation Path - @Test - fun `setDerivationPath should update path`() { - viewModel.setDerivationPath("m/49'/0'/0'/0/0") - - assertEquals("m/49'/0'/0'/0/0", viewModel.uiState.value.derivationPath) - } + fun `connect should call trezorRepo connect with deviceId`() = test { + val deviceId = "device-123" + whenever(trezorRepo.connect(deviceId)).thenReturn(Result.failure(RuntimeException("test"))) - // endregion + sut.connect(deviceId) + advanceUntilIdle() - // region Message Signing State + verify(trezorRepo).connect(deviceId) + } @Test - fun `setMessageToSign should update message`() { - viewModel.setMessageToSign("Test message") + fun `disconnect should call trezorRepo disconnect`() = test { + whenever(trezorRepo.disconnect()).thenReturn(Result.success(Unit)) - assertEquals("Test message", viewModel.uiState.value.messageToSign) + sut.disconnect() + advanceUntilIdle() + + verify(trezorRepo).disconnect() } @Test - fun `initial message should be default greeting`() { - assertEquals("Hello, Trezor!", viewModel.uiState.value.messageToSign) - } + fun `getAddress should clear isGettingAddress on success`() = test { + whenever(trezorRepo.getAddress(any(), any(), any(), any())) + .thenReturn(Result.success(mock())) - // endregion + sut.getAddress() + advanceUntilIdle() - // region Lookup Input + assertFalse(sut.uiState.value.isGettingAddress) + } @Test - fun `setLookupInput should update input`() { - viewModel.setLookupInput("xpub6C...") + fun `getAddress should clear isGettingAddress on failure`() = test { + whenever(trezorRepo.getAddress(any(), any(), any(), any())) + .thenReturn(Result.failure(RuntimeException("test"))) - assertEquals("xpub6C...", viewModel.uiState.value.lookupInput) + sut.getAddress() + advanceUntilIdle() + + assertFalse(sut.uiState.value.isGettingAddress) } - // endregion + @Test + fun `signMessage should not call repo when message is blank`() = test { + sut.setMessageToSign("") + + sut.signMessage() + advanceUntilIdle() - // region Send Flow State + verify(trezorRepo, never()).signMessage(any(), any(), any()) + } @Test - fun `setSendAddress should update address`() { - viewModel.setSendAddress("bc1qtest...") + fun `verifyMessage should not call repo when no signature exists`() = test { + sut.verifyMessage() + advanceUntilIdle() - assertEquals("bc1qtest...", viewModel.uiState.value.sendAddress) + verify(trezorRepo, never()).verifyMessage(any(), any(), any(), any()) } @Test - fun `setSendAmount should update amount`() { - viewModel.setSendAmount("50000") + fun `broadcastSignedTx should not call repo when no signedTxResult`() = test { + sut.broadcastSignedTx() + advanceUntilIdle() - assertEquals("50000", viewModel.uiState.value.sendAmountSats) + verify(trezorRepo, never()).broadcastRawTx(any(), any()) } @Test - fun `setSendFeeRate should update fee rate`() { - viewModel.setSendFeeRate("10") + fun `clearError should call trezorRepo clearError`() { + sut.clearError() - assertEquals("10", viewModel.uiState.value.sendFeeRate) + verify(trezorRepo).clearError() } @Test - fun `toggleSendMax should toggle isSendMax`() { - assertFalse(viewModel.uiState.value.isSendMax) - - viewModel.toggleSendMax() - assertTrue(viewModel.uiState.value.isSendMax) + fun `submitPairingCode should call trezorRepo submitPairingCode`() { + val code = "123456" + sut.submitPairingCode(code) - viewModel.toggleSendMax() - assertFalse(viewModel.uiState.value.isSendMax) + verify(trezorRepo).submitPairingCode(code) } @Test - fun `resetSendFlow should clear all send fields`() { - viewModel.setSendAddress("bc1qtest") - viewModel.setSendAmount("50000") - viewModel.setSendFeeRate("10") - viewModel.toggleSendMax() + fun `cancelPairingCode should call trezorRepo cancelPairingCode`() { + sut.cancelPairingCode() - viewModel.resetSendFlow() - - val state = viewModel.uiState.value - assertEquals("", state.sendAddress) - assertEquals("", state.sendAmountSats) - assertEquals("2", state.sendFeeRate) - assertFalse(state.isSendMax) - assertFalse(state.isComposing) - assertFalse(state.isSigning) - assertNull(state.precomposedResult) - assertNull(state.signedTxResult) - assertEquals(SendStep.FORM, state.sendStep) - assertNull(state.broadcastTxid) + verify(trezorRepo).cancelPairingCode() } @Test - fun `backToComposeForm should reset to FORM step`() { - viewModel.backToComposeForm() + fun `hasKnownDevices should delegate to trezorRepo`() { + whenever(trezorRepo.hasKnownDevices()).thenReturn(true) - val state = viewModel.uiState.value - assertEquals(SendStep.FORM, state.sendStep) - assertNull(state.precomposedResult) - assertNull(state.signedTxResult) + assertTrue(sut.hasKnownDevices()) + verify(trezorRepo).hasKnownDevices() } // endregion + + private fun createViewModel() = TrezorViewModel( + bgDispatcher = testDispatcher, + trezorRepo = trezorRepo, + ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70e2e2d71..76d849ba2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version = "1 appcompat = { module = "androidx.appcompat:appcompat", version = "1.7.1" } barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version = "17.3.0" } biometric = { module = "androidx.biometric:biometric", version = "1.4.0-alpha05" } -bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.47" } +bitkit-core = { module = "com.synonym:bitkit-core-android", version = "0.1.48" } bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version = "1.83" } camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } From bc3a0fc8880a2cd9f30e410676c71a2ed42328a1 Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 17 Mar 2026 07:42:15 -0400 Subject: [PATCH 37/48] fix: add test dispatcher --- app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 35048a0d5..1f94a5c4f 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -18,6 +18,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import to.bitkit.data.TrezorStore import to.bitkit.env.Env import to.bitkit.services.TrezorService import to.bitkit.services.TrezorTransport @@ -47,6 +48,7 @@ class TrezorRepoTest : BaseUnitTest() { private val context = mock() private val trezorService = mock() private val trezorTransport = mock() + private val trezorStore = mock() private val prefs = mock() private val prefsEditor = mock() @@ -68,7 +70,8 @@ class TrezorRepoTest : BaseUnitTest() { context = context, trezorService = trezorService, trezorTransport = trezorTransport, - bgDispatcher = testDispatcher, + trezorStore = trezorStore, + ioDispatcher = testDispatcher, ) @Suppress("LongParameterList") From a851b877cbdd4eb34b6265ab36fe8afd2262ca1e Mon Sep 17 00:00:00 2001 From: coreyphillips Date: Tue, 17 Mar 2026 08:09:05 -0400 Subject: [PATCH 38/48] fix: trezor test --- app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt index 1f94a5c4f..d7271223e 100644 --- a/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/TrezorRepoTest.kt @@ -64,6 +64,7 @@ class TrezorRepoTest : BaseUnitTest() { whenever(trezorTransport.needsPairingCode).thenReturn(MutableStateFlow(false)) whenever(trezorTransport.externalDisconnect).thenReturn(MutableSharedFlow()) whenever(context.filesDir).thenReturn(tempFolder.root) + whenever { trezorStore.loadKnownDevices() }.thenReturn(emptyList()) } private fun createSut(): TrezorRepo = TrezorRepo( From 5eb64307c6f99810c4027f32d7dbd72e770f7c8e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 07:38:00 -0300 Subject: [PATCH 39/48] fix: add stable annotation --- .../main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt index 96af95b19..fe2bbdb2a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/TrezorViewModel.kt @@ -1,5 +1,6 @@ package to.bitkit.ui.screens.trezor +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.AccountInfoResult @@ -562,6 +563,7 @@ class TrezorViewModel @Inject constructor( } } +@Stable data class TrezorUiState( val selectedNetwork: BitkitCoreNetwork = Env.network.toCoreNetwork(), val addressIndex: Int = 0, From 5fbc9c01149f07d5db88f2eed379418442376374 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 09:00:25 -0300 Subject: [PATCH 40/48] refactor: convert try/catch call to runCatching were possible --- .../to/bitkit/services/TrezorTransport.kt | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 00c2379d5..76b4deac6 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -182,36 +182,37 @@ class TrezorTransport @Inject constructor( // ==================== TrezorTransportCallback Implementation ==================== - @Suppress("TooGenericExceptionCaught") override fun enumerateDevices(): List { val devices = mutableListOf() // Enumerate USB devices - try { - val usbDevices = usbManager.deviceList.values + runCatching { + usbManager.deviceList.values .filter { isTrezorDevice(it) } .map { device -> NativeDeviceInfo( path = device.deviceName, transportType = "usb", - name = try { device.productName } catch (_: SecurityException) { null }, + name = runCatching { device.productName }.getOrNull(), vendorId = device.vendorId.toUShort(), productId = device.productId.toUShort(), ) } - devices.addAll(usbDevices) - Logger.debug("USB enumerate found '${usbDevices.size}' Trezor device(s)", context = TAG) - } catch (e: Exception) { - Logger.error("USB enumerate failed", e, context = TAG) + }.onSuccess { + devices.addAll(it) + Logger.debug("USB enumerate found '${it.size}' Trezor device(s)", context = TAG) + }.onFailure { + Logger.error("USB enumerate failed", it, context = TAG) } // Enumerate Bluetooth devices - try { - val bleDevices = enumerateBleDevices() - devices.addAll(bleDevices) - Logger.debug("BLE enumerate found '${bleDevices.size}' Trezor device(s)", context = TAG) - } catch (e: Exception) { - Logger.error("BLE enumerate failed", e, context = TAG) + runCatching { + enumerateBleDevices() + }.onSuccess { + devices.addAll(it) + Logger.debug("BLE enumerate found '${it.size}' Trezor device(s)", context = TAG) + }.onFailure { + Logger.error("BLE enumerate failed", it, context = TAG) } Logger.info("Total enumerate found '${devices.size}' Trezor device(s)", context = TAG) @@ -448,16 +449,15 @@ class TrezorTransport @Inject constructor( } } - @Suppress("TooGenericExceptionCaught") fun clearDeviceCredential(deviceId: String) { - try { + runCatching { val file = credentialFile(deviceId) TrezorDebugLog.log("CLEAR", "clearDeviceCredential for: $deviceId, exists=${file.exists()}") file.delete() Logger.info("Cleared device credential for: '$deviceId'", context = TAG) - } catch (e: Exception) { - TrezorDebugLog.log("CLEAR", "EXCEPTION: ${e.message}") - Logger.error("Failed to clear device credential", e, context = TAG) + }.onFailure { + TrezorDebugLog.log("CLEAR", "EXCEPTION: ${it.message}") + Logger.error("Failed to clear device credential", it, context = TAG) } } From 29393564a5f1bd748917b52f27f54878f3108390 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 09:01:51 -0300 Subject: [PATCH 41/48] refactor: apply prefs extension function --- .../main/java/to/bitkit/services/TrezorTransport.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 76b4deac6..961569fe5 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -49,6 +49,7 @@ import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import androidx.core.content.edit /** * Transport callback implementation for Trezor communication. @@ -141,7 +142,7 @@ class TrezorTransport @Inject constructor( migrated++ } if (migrated > 0) { - espPrefs.edit().clear().commit() + espPrefs.edit(commit = true) { clear() } Logger.info("Migrated '$migrated' THP credentials from SharedPreferences to files", context = TAG) } } catch (e: Exception) { @@ -266,7 +267,7 @@ class TrezorTransport @Inject constructor( override fun callMessage( path: String, messageType: UShort, - data: ByteArray + data: ByteArray, ): TrezorCallMessageResult? { // For BLE/THP devices, the Rust side now handles THP protocol directly. // This callback returns null to let Rust use its built-in THP implementation. @@ -1076,7 +1077,7 @@ class TrezorTransport @Inject constructor( override fun onCharacteristicChanged( gatt: BluetoothGatt, - characteristic: BluetoothGattCharacteristic + characteristic: BluetoothGattCharacteristic, ) { val path = "ble:${gatt.device.address}" val connection = bleConnections[path] ?: return @@ -1099,7 +1100,7 @@ class TrezorTransport @Inject constructor( override fun onCharacteristicWrite( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, - status: Int + status: Int, ) { val path = "ble:${gatt.device.address}" val connection = bleConnections[path] ?: return @@ -1113,7 +1114,7 @@ class TrezorTransport @Inject constructor( override fun onDescriptorWrite( gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, - status: Int + status: Int, ) { val path = "ble:${gatt.device.address}" val connection = bleConnections[path] ?: return From 25809059c4ad0f0fa3b1f66553a1b3c21194ec19 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 09:02:19 -0300 Subject: [PATCH 42/48] chore: lint --- app/src/main/java/to/bitkit/services/TrezorTransport.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 961569fe5..276cb8731 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -27,6 +27,7 @@ import android.hardware.usb.UsbManager import android.os.Handler import android.os.Looper import android.os.ParcelUuid +import androidx.core.content.edit import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult import com.synonym.bitkitcore.TrezorTransportCallback @@ -49,7 +50,6 @@ import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton -import androidx.core.content.edit /** * Transport callback implementation for Trezor communication. From 3f3746ff76bbebb6a7900f28b58f51d55d6f6f78 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 09:09:32 -0300 Subject: [PATCH 43/48] fix: warnings --- .../java/to/bitkit/services/TrezorTransport.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 276cb8731..263c7ca0f 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -27,6 +27,7 @@ import android.hardware.usb.UsbManager import android.os.Handler import android.os.Looper import android.os.ParcelUuid +import androidx.core.content.ContextCompat import androidx.core.content.edit import com.synonym.bitkitcore.NativeDeviceInfo import com.synonym.bitkitcore.TrezorCallMessageResult @@ -497,10 +498,11 @@ class TrezorTransport @Inject constructor( PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, ) - context.registerReceiver( + ContextCompat.registerReceiver( + context, receiver, IntentFilter(ACTION_USB_PERMISSION), - Context.RECEIVER_NOT_EXPORTED, + ContextCompat.RECEIVER_NOT_EXPORTED, ) try { @@ -530,13 +532,11 @@ class TrezorTransport @Inject constructor( for (i in 0 until usbInterface.endpointCount) { val endpoint = usbInterface.getEndpoint(i) - when { - endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && - endpoint.direction == UsbConstants.USB_DIR_IN -> { + when (endpoint.direction) { + UsbConstants.USB_DIR_IN if endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT -> { readEndpoint = endpoint } - endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && - endpoint.direction == UsbConstants.USB_DIR_OUT -> { + UsbConstants.USB_DIR_OUT if endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT -> { writeEndpoint = endpoint } } @@ -716,6 +716,7 @@ class TrezorTransport @Inject constructor( } } + @SuppressLint("MissingPermission") private val bleScanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { val device = result.device @@ -1075,6 +1076,7 @@ class TrezorTransport @Inject constructor( Logger.info("BLE services discovered: '$path'", context = TAG) } + @Suppress("OVERRIDE_DEPRECATION") override fun onCharacteristicChanged( gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, From 2806d72cacada5a4e97e542d011aa178c5fb63c6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 09:23:02 -0300 Subject: [PATCH 44/48] chore: remove dead code --- app/src/main/java/to/bitkit/repositories/TrezorRepo.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 16abe277a..2a1835789 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -514,13 +514,6 @@ class TrezorRepo @Inject constructor( BitkitCoreNetwork.SIGNET -> "ssl://electrum.blockstream.info:60002" } - private fun electrumUrlForNetwork(network: TrezorCoinType): String = when (network) { - TrezorCoinType.BITCOIN -> Env.electrumUrlForNetwork(BitkitCoreNetwork.BITCOIN) - TrezorCoinType.TESTNET -> Env.electrumUrlForNetwork(BitkitCoreNetwork.TESTNET) - TrezorCoinType.REGTEST -> Env.electrumUrlForNetwork(BitkitCoreNetwork.REGTEST) - TrezorCoinType.SIGNET -> Env.electrumUrlForNetwork(BitkitCoreNetwork.SIGNET) - } - private suspend fun ensureConnected() { if (trezorService.isConnected()) return val deviceId = _state.value.connectedDeviceId From e7989467374a911fa2e41839a0408f15cf35e023 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 09:31:24 -0300 Subject: [PATCH 45/48] refactor: get Electrum url from Env --- app/src/main/java/to/bitkit/repositories/TrezorRepo.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt index 2a1835789..aab506560 100644 --- a/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/TrezorRepo.kt @@ -506,13 +506,7 @@ class TrezorRepo @Inject constructor( }.onFailure { Logger.error("Failed to save known devices", it, context = TAG) } } - private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = when (network) { - BitkitCoreNetwork.BITCOIN -> "ssl://bitkit.to:9999" - BitkitCoreNetwork.TESTNET -> "ssl://electrum.blockstream.info:60002" - BitkitCoreNetwork.TESTNET4 -> "ssl://electrum.blockstream.info:60002" - BitkitCoreNetwork.REGTEST -> "ssl://electrs.bitkit.stag0.blocktank.to:9999" - BitkitCoreNetwork.SIGNET -> "ssl://electrum.blockstream.info:60002" - } + private fun electrumUrlForNetwork(network: BitkitCoreNetwork): String = Env.electrumUrlForNetwork(network) private suspend fun ensureConnected() { if (trezorService.isConnected()) return From dee54eaf5e5056c09161fb17eb3ce0352e5fcc7f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 10:47:22 -0300 Subject: [PATCH 46/48] refactor: move hardware wallet panel to dev settings --- app/src/main/java/to/bitkit/ui/ContentView.kt | 6 +++--- .../ui/screens/settings/DevSettingsScreen.kt | 3 +++ .../ui/settings/AdvancedSettingsScreen.kt | 18 ------------------ .../ui/settings/AdvancedSettingsViewModel.kt | 3 --- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index e01b4cb8a..e6300bbd1 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -877,6 +877,9 @@ private fun NavGraphBuilder.settings( composableWithDefaultTransitions { DevSettingsScreen(navController) } + composableWithDefaultTransitions { + TrezorScreen(navController) + } composableWithDefaultTransitions { LdkDebugScreen(navController) } @@ -1023,9 +1026,6 @@ private fun NavGraphBuilder.advancedSettings(navController: NavHostController) { composableWithDefaultTransitions { NodeInfoScreen(navController) } - composableWithDefaultTransitions { - TrezorScreen(navController) - } } private fun NavGraphBuilder.aboutSettings(navController: NavHostController) { diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 74b480188..c329850f0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -56,6 +56,9 @@ fun DevSettingsScreen( SettingsButtonRow("VSS") { navController.navigateTo(Routes.VssDebug) } SettingsButtonRow("Probing Tool") { navController.navigateTo(Routes.ProbingTool) } + SectionHeader("HARDWARE WALLET") + SettingsButtonRow("Trezor") { navController.navigateTo(Routes.Trezor) } + SectionHeader("LOGS") SettingsButtonRow("Logs") { navController.navigateTo(Routes.Logs) } SettingsTextButtonRow( diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt index 96c9820d9..db5235cca 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsScreen.kt @@ -37,12 +37,10 @@ fun AdvancedSettingsScreen( navController: NavController, viewModel: AdvancedSettingsViewModel = hiltViewModel(), ) { - val isDevModeEnabled by viewModel.isDevModeEnabled.collectAsStateWithLifecycle() var showResetSuggestionsDialog by remember { mutableStateOf(false) } val selectedAddressTypeName by viewModel.selectedAddressTypeName.collectAsStateWithLifecycle() Content( - isDevModeEnabled = isDevModeEnabled, showResetSuggestionsDialog = showResetSuggestionsDialog, selectedAddressTypeName = selectedAddressTypeName, onBack = { navController.popBackStack() }, @@ -67,9 +65,6 @@ fun AdvancedSettingsScreen( onAddressViewerClick = { navController.navigateTo(Routes.AddressViewer) }, - onTrezorClick = { - navController.navigate(Routes.Trezor) - }, onSuggestionsResetClick = { showResetSuggestionsDialog = true }, onResetSuggestionsDialogConfirm = { viewModel.resetSuggestions() @@ -82,7 +77,6 @@ fun AdvancedSettingsScreen( @Composable private fun Content( - isDevModeEnabled: Boolean = false, showResetSuggestionsDialog: Boolean, selectedAddressTypeName: String = "", onBack: () -> Unit = {}, @@ -93,7 +87,6 @@ private fun Content( onElectrumServerClick: () -> Unit = {}, onRgsServerClick: () -> Unit = {}, onAddressViewerClick: () -> Unit = {}, - onTrezorClick: () -> Unit = {}, onSuggestionsResetClick: () -> Unit = {}, onResetSuggestionsDialogConfirm: () -> Unit = {}, onResetSuggestionsDialogCancel: () -> Unit = {}, @@ -158,17 +151,6 @@ private fun Content( modifier = Modifier.testTag("RGSServer"), ) - // Hardware Wallet Section - if (isDevModeEnabled) { - SectionHeader(title = "Hardware Wallet") - - SettingsButtonRow( - title = "Trezor", - onClick = onTrezorClick, - modifier = Modifier.testTag("Trezor"), - ) - } - // Other Section SectionHeader(title = stringResource(R.string.settings__adv__section_other)) diff --git a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt index f9244de13..3fe3a0ede 100644 --- a/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/AdvancedSettingsViewModel.kt @@ -17,9 +17,6 @@ class AdvancedSettingsViewModel @Inject constructor( private val settingsStore: SettingsStore, ) : ViewModel() { - val isDevModeEnabled = settingsStore.data.map { it.isDevModeEnabled } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) - val selectedAddressTypeName = settingsStore.data .map { it.selectedAddressType.toAddressType()?.addressTypeInfo()?.shortName ?: "" } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "") From 7dfe56c6d3856ba4dbe7148cac8c2499a95eb58e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 11:20:54 -0300 Subject: [PATCH 47/48] fix: bulkTransfer() is only valid for bulk endpoints and returns -1 on interrupt endpoints (though it may accidentally work on some kernels). This commit implements UsbRequest API for propper interrupt endpoints handling --- .../to/bitkit/services/TrezorTransport.kt | 82 ++++++++++++------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/to/bitkit/services/TrezorTransport.kt b/app/src/main/java/to/bitkit/services/TrezorTransport.kt index 263c7ca0f..13b946c6d 100644 --- a/app/src/main/java/to/bitkit/services/TrezorTransport.kt +++ b/app/src/main/java/to/bitkit/services/TrezorTransport.kt @@ -24,6 +24,7 @@ import android.hardware.usb.UsbDeviceConnection import android.hardware.usb.UsbEndpoint import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager +import android.hardware.usb.UsbRequest import android.os.Handler import android.os.Looper import android.os.ParcelUuid @@ -44,6 +45,7 @@ import to.bitkit.ext.bluetoothManager import to.bitkit.ext.usbManager import to.bitkit.utils.Logger import java.io.File +import java.nio.ByteBuffer import java.util.UUID import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch @@ -624,25 +626,39 @@ class TrezorTransport @Inject constructor( error = "Device not open: $path", ) - val buffer = ByteArray(USB_CHUNK_SIZE) - val bytesRead = openDevice.connection.bulkTransfer( - openDevice.readEndpoint, - buffer, - buffer.size, - READ_TIMEOUT_MS, - ) + val buffer = ByteBuffer.allocate(USB_CHUNK_SIZE) + val request = UsbRequest() + try { + if (!request.initialize(openDevice.connection, openDevice.readEndpoint)) { + return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Failed to initialize USB read request", + ) + } + if (!request.queue(buffer)) { + return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "Failed to queue USB read request", + ) + } + openDevice.connection.requestWait(READ_TIMEOUT_MS.toLong()) + ?: return TrezorTransportReadResult( + success = false, + data = byteArrayOf(), + error = "USB read timed out", + ) - if (bytesRead < 0) { - return TrezorTransportReadResult( - success = false, - data = byteArrayOf(), - error = "Read failed: $bytesRead", - ) + buffer.flip() + val bytesRead = buffer.remaining() + val data = ByteArray(bytesRead) + buffer.get(data) + Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) + TrezorTransportReadResult(success = true, data = data, error = "") + } finally { + request.close() } - - val data = buffer.copyOf(bytesRead) - Logger.debug("USB read '$bytesRead' bytes from '$path'", context = TAG) - TrezorTransportReadResult(success = true, data = data, error = "") } catch (e: Exception) { Logger.error("USB read failed", e, context = TAG) TrezorTransportReadResult(success = false, data = byteArrayOf(), error = e.message ?: "Unknown error") @@ -655,19 +671,29 @@ class TrezorTransport @Inject constructor( val openDevice = usbConnections[path] ?: return TrezorTransportWriteResult(success = false, error = "Device not open: $path") - val bytesWritten = openDevice.connection.bulkTransfer( - openDevice.writeEndpoint, - data, - data.size, - WRITE_TIMEOUT_MS, - ) + val buffer = ByteBuffer.wrap(data) + val request = UsbRequest() + try { + if (!request.initialize(openDevice.connection, openDevice.writeEndpoint)) { + return TrezorTransportWriteResult( + success = false, + error = "Failed to initialize USB write request", + ) + } + if (!request.queue(buffer)) { + return TrezorTransportWriteResult( + success = false, + error = "Failed to queue USB write request", + ) + } + openDevice.connection.requestWait(WRITE_TIMEOUT_MS.toLong()) + ?: return TrezorTransportWriteResult(success = false, error = "USB write timed out") - if (bytesWritten < 0) { - return TrezorTransportWriteResult(success = false, error = "Write failed: $bytesWritten") + Logger.debug("USB wrote '${data.size}' bytes to '$path'", context = TAG) + TrezorTransportWriteResult(success = true, error = "") + } finally { + request.close() } - - Logger.debug("USB wrote '$bytesWritten' bytes to '$path'", context = TAG) - TrezorTransportWriteResult(success = true, error = "") } catch (e: Exception) { Logger.error("USB write failed", e, context = TAG) TrezorTransportWriteResult(success = false, error = e.message ?: "Unknown error") From e80dc32a8a070d4677275d04e5682a84cf471fcb Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 19 Mar 2026 11:38:10 -0300 Subject: [PATCH 48/48] fix: remove redundant modifier --- .../java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt index b9cb5c2c4..20ce7df30 100644 --- a/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt +++ b/app/src/main/java/to/bitkit/ui/screens/trezor/BalanceLookupSection.kt @@ -81,7 +81,6 @@ internal fun BalanceLookupSection( onClick = onLookup, enabled = !uiState.isLookingUp && uiState.lookupInput.isNotBlank(), size = ButtonSize.Small, - modifier = Modifier.fillMaxWidth(), ) AnimatedVisibility(visible = uiState.accountInfoResult != null) {