Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
acc1278b34
|
|||
|
bc678d26ad
|
|||
|
bb2a80a5e3
|
|||
|
b107358266
|
|||
|
02a53c8219
|
|||
|
15a02cac1c
|
|||
|
35a1748055
|
|||
|
28682bba41
|
|||
|
25484addfb
|
|||
|
728c7d2aa3
|
|||
|
b24949c117
|
|||
|
28e5878668
|
|||
|
b1e73533f6
|
|||
|
3a5b9459a9
|
|||
|
9c9729e268
|
|||
|
399cfbf108
|
|||
|
19f4d01015
|
|||
|
8c40322ff0
|
|||
|
782e2e7674
|
|||
|
89a9731797
|
|||
|
50badc7d54
|
|||
|
d4f86bb738
|
|||
|
b35f44f35b
|
|||
|
1a58ce8b54
|
|||
|
2dd84ec50a
|
|||
|
33651ca107
|
|||
|
ae307e3118
|
|||
|
3ab75bff92
|
|||
|
1753d648bd
|
|||
|
423b0bf1e1
|
|||
|
d713047970
|
|||
|
d59a6fad82
|
|||
|
8e47101401
|
|||
|
9431a90cd0
|
|||
|
3a10f36c39
|
|||
|
27c428d1f6
|
|||
|
7b4f650f4e
|
|||
|
00e109562b
|
|||
|
cd4b3fef8b
|
|||
|
389344a192
|
|||
|
04af4e1bbd
|
|||
|
6197152f6e
|
|||
|
ecbbb3ed6b
|
|||
|
b26f9deba7
|
|||
|
2b710f00c6
|
|||
|
9bbb024d69
|
|||
|
a60a18ee7f
|
|||
|
7782ac57f1
|
|||
|
36ab100aa7
|
|||
|
83499aed35
|
|||
|
9cc92aff02
|
|||
|
153c98862e
|
|||
|
b5bf950271
|
|||
|
bb2c4f0158
|
|||
|
ccc9e11d55
|
|||
|
93405aade2
|
|||
|
fd531066cd
|
|||
|
ffe50467e7
|
|||
|
d4104e2ed2
|
|||
|
7864655a82
|
|||
|
99a32dc9ed
|
|||
|
4c19551ee7
|
|||
|
0a6a199e9e
|
|||
|
480f4e42d7
|
|||
|
92bbe383b8
|
|||
|
4b6c5e5d8b
|
|||
|
253e5400d8
|
|||
|
b9be7cd1a3
|
|||
|
fc031f1f2a
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,5 +15,6 @@
|
|||||||
local.properties
|
local.properties
|
||||||
docs/mibapi/tmp
|
docs/mibapi/tmp
|
||||||
docs/bmlapi/tmp
|
docs/bmlapi/tmp
|
||||||
|
docs/fahipayapi/tmp
|
||||||
tmp
|
tmp
|
||||||
app/key.jks
|
app/key.jks
|
||||||
|
|||||||
19
.idea/deploymentTargetSelector.xml
generated
19
.idea/deploymentTargetSelector.xml
generated
@@ -4,14 +4,27 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2025-04-17T17:14:16.980728222Z">
|
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection>
|
||||||
|
<targets>
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</targets>
|
||||||
|
</DialogSelection>
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
232
LICENSE
Normal file
232
LICENSE
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
“This License” refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
||||||
|
|
||||||
|
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
||||||
|
|
||||||
|
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
||||||
|
|
||||||
|
A “covered work” means either the unmodified Program or a work based on the Program.
|
||||||
|
|
||||||
|
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
||||||
|
|
||||||
|
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
||||||
|
|
||||||
|
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
||||||
|
|
||||||
|
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
||||||
|
|
||||||
|
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
||||||
|
|
||||||
|
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
ISODroid
|
||||||
|
Copyright (C) 2026 shihaam
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
ISODroid Copyright (C) 2026 shihaam
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||||
72
README.md
Normal file
72
README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# BasedBank
|
||||||
|
|
||||||
|
A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (Bank of Maldives), and Fahipay into a single interface — with no analytics, no tracking, and no phone-home behaviour outside the banks themselves.
|
||||||
|
|
||||||
|
[](https://sladge.net)
|
||||||
|
[](LICENSE)
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
|
||||||
|
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
|
||||||
|
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
|
||||||
|
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
|
||||||
|
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
|
||||||
|
- **QR payments** — scan PayMV QR codes to pre-fill transfers
|
||||||
|
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
|
||||||
|
- **MIB financing** — view active financing deals
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
|
||||||
|
|
||||||
|
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
|
||||||
|
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
|
||||||
|
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
|
||||||
|
- All sensitive data excluded from Android cloud backup
|
||||||
|
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
|
||||||
|
|
||||||
|
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
|
||||||
|
|
||||||
|
## Supported banks
|
||||||
|
|
||||||
|
| Bank | Login | Accounts | History | Transfers | Contacts |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Android 8.0+ (API 26)
|
||||||
|
- Existing accounts with MIB, BML, or Fahipay
|
||||||
|
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
|
||||||
|
|
||||||
|
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
|
||||||
|
|
||||||
|
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
|
||||||
|
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
|
||||||
|
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||||
|
|
||||||
|
## Disclaimer
|
||||||
|
|
||||||
|
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 3
|
||||||
versionName = "1.0"
|
versionName = "1.0.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -64,13 +64,18 @@ dependencies {
|
|||||||
// RecyclerView for accounts list
|
// RecyclerView for accounts list
|
||||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||||
|
|
||||||
|
// CircularProgressDrawable for spinning search icons
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
// OkHttp for API calls
|
// OkHttp for API calls
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||||
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
|
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// ZXing core for QR code generation
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
|
||||||
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
||||||
implementation("androidx.camera:camera-core:1.4.2")
|
implementation("androidx.camera:camera-core:1.4.2")
|
||||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||||
|
|||||||
@@ -48,7 +48,8 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
android:exported="false" />
|
android:exported="false"
|
||||||
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.QrScannerActivity"
|
android:name=".ui.home.QrScannerActivity"
|
||||||
|
|||||||
@@ -4,34 +4,108 @@ import android.app.Application
|
|||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.MibSession
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
class BasedBankApp : Application() {
|
class BasedBankApp : Application() {
|
||||||
|
|
||||||
// Held in memory after successful login; cleared on logout
|
// Held in memory after successful login; cleared on logout
|
||||||
var accounts: List<MibAccount> = emptyList()
|
var accounts: List<MibAccount> = emptyList()
|
||||||
var fullName: String = ""
|
var fullName: String = ""
|
||||||
var mibSession: MibSession? = null
|
|
||||||
var mibProfiles: List<MibProfile> = emptyList()
|
/** Active MIB sessions keyed by loginId (= MIB username). */
|
||||||
var bmlSession: BmlSession? = null
|
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
|
||||||
|
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
|
||||||
|
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
|
||||||
|
var mibAccounts: List<MibAccount> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
|
||||||
|
* Use [bmlSessionFor] to look up the session for an account.
|
||||||
|
*/
|
||||||
|
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||||
|
/** BML profiles per loginId (= BML username). */
|
||||||
|
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||||
|
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||||
|
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
|
||||||
var bmlAccounts: List<MibAccount> = emptyList()
|
var bmlAccounts: List<MibAccount> = emptyList()
|
||||||
|
|
||||||
|
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
||||||
|
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||||
|
var fahipayAccounts: List<MibAccount> = emptyList()
|
||||||
|
|
||||||
|
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||||
|
fun mibSessionFor(account: MibAccount): MibSession? =
|
||||||
|
mibSessions[account.loginTag.removePrefix("mib_")]
|
||||||
|
|
||||||
|
/** Returns any available MIB session. */
|
||||||
|
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
|
||||||
|
|
||||||
|
/** Returns all MIB profiles across all logins. */
|
||||||
|
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
|
||||||
|
|
||||||
|
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
|
||||||
|
fun mibFlowFor(loginId: String): MibLoginFlow =
|
||||||
|
mibLoginFlows.getOrPut(loginId) {
|
||||||
|
MibLoginFlow(CredentialStore(this)).also { flow ->
|
||||||
|
flow.onSessionRefreshed = { session, profiles ->
|
||||||
|
mibSessions[loginId] = session
|
||||||
|
mibProfilesMap[loginId] = profiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns any available MibLoginFlow. */
|
||||||
|
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
|
||||||
|
|
||||||
|
// ─── BML helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the BML session for the given account.
|
||||||
|
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
|
||||||
|
*/
|
||||||
|
fun bmlSessionFor(account: MibAccount): BmlSession? {
|
||||||
|
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
|
||||||
|
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns any available BML session (for non-account-specific operations). */
|
||||||
|
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns any active BML session for the given loginId.
|
||||||
|
* Tries all profiles for that login; falls back to legacy loginId key.
|
||||||
|
*/
|
||||||
|
fun anyBmlSessionFor(loginId: String): BmlSession? {
|
||||||
|
val profiles = bmlProfilesMap[loginId]
|
||||||
|
if (!profiles.isNullOrEmpty()) {
|
||||||
|
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
|
||||||
|
}
|
||||||
|
return bmlSessions[loginId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
|
||||||
|
fun bmlFlowFor(loginId: String): BmlLoginFlow =
|
||||||
|
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
|
||||||
|
|
||||||
|
// ─── Fahipay helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
|
||||||
|
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
|
||||||
|
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||||
|
|
||||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||||
val mibMutex = Mutex()
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
val mibLoginFlow by lazy {
|
|
||||||
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow ->
|
|
||||||
flow.onSessionRefreshed = { session, profiles ->
|
|
||||||
mibSession = session
|
|
||||||
mibProfiles = profiles
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ import android.os.Bundle
|
|||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
import java.security.MessageDigest
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
class LockActivity : AppCompatActivity() {
|
class LockActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -22,18 +32,40 @@ class LockActivity : AppCompatActivity() {
|
|||||||
private lateinit var salt: String
|
private lateinit var salt: String
|
||||||
private lateinit var storedHash: String
|
private lateinit var storedHash: String
|
||||||
private var biometricsEnabled = false
|
private var biometricsEnabled = false
|
||||||
|
private var isVerifying = false
|
||||||
|
|
||||||
|
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_ATTEMPTS = 5
|
||||||
|
private const val LOCKOUT_MS = 30_000L
|
||||||
|
const val EXTRA_RESUME = "resume"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||||
salt = prefs.getString("security_salt", "") ?: ""
|
|
||||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
|
||||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
|
||||||
|
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||||
|
salt = stored.first
|
||||||
|
storedHash = stored.second
|
||||||
|
|
||||||
if (method == "pin") {
|
if (method == "pin") {
|
||||||
binding.viewPin.visibility = View.VISIBLE
|
binding.viewPin.visibility = View.VISIBLE
|
||||||
buildNumpad()
|
buildNumpad()
|
||||||
@@ -48,6 +80,12 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (biometricsEnabled) triggerBiometric()
|
if (biometricsEnabled) triggerBiometric()
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
moveTaskToBack(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNumpad() {
|
private fun buildNumpad() {
|
||||||
@@ -92,6 +130,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleKey(key: String) {
|
private fun handleKey(key: String) {
|
||||||
|
if (isVerifying) return
|
||||||
when (key) {
|
when (key) {
|
||||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||||
@@ -105,13 +144,20 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun verifyPin() {
|
private fun verifyPin() {
|
||||||
|
if (checkAndShowLockout()) return
|
||||||
val entered = pinDigits.joinToString("")
|
val entered = pinDigits.joinToString("")
|
||||||
if (verify(entered)) {
|
pinDigits.clear()
|
||||||
proceed()
|
updateDots()
|
||||||
} else {
|
isVerifying = true
|
||||||
binding.tvLockPinDots.text = getString(R.string.unlock_failed)
|
lifecycleScope.launch {
|
||||||
pinDigits.clear()
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
binding.root.postDelayed({ updateDots() }, 1200)
|
isVerifying = false
|
||||||
|
if (ok) {
|
||||||
|
resetFailures()
|
||||||
|
proceed()
|
||||||
|
} else {
|
||||||
|
showFailure()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,18 +167,59 @@ class LockActivity : AppCompatActivity() {
|
|||||||
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
|
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (verify(pattern.joinToString(""))) {
|
if (checkAndShowLockout()) {
|
||||||
proceed()
|
|
||||||
} else {
|
|
||||||
binding.lockPatternView.showError()
|
binding.lockPatternView.showError()
|
||||||
binding.tvPatternHint.text = getString(R.string.unlock_failed)
|
return
|
||||||
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
|
}
|
||||||
|
val entered = pattern.joinToString("")
|
||||||
|
isVerifying = true
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
|
isVerifying = false
|
||||||
|
if (ok) {
|
||||||
|
resetFailures()
|
||||||
|
proceed()
|
||||||
|
} else {
|
||||||
|
binding.lockPatternView.showError()
|
||||||
|
val msg = failureMessage()
|
||||||
|
binding.tvPatternHint.text = msg
|
||||||
|
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true and shows the lockout message if currently locked out. */
|
||||||
|
private fun checkAndShowLockout(): Boolean {
|
||||||
|
val remaining = lockoutRemainingMs()
|
||||||
|
if (remaining <= 0) return false
|
||||||
|
val secs = ((remaining + 999L) / 1000L).toInt()
|
||||||
|
val msg = getString(R.string.unlock_locked_out, secs)
|
||||||
|
binding.tvLockPinDots.text = msg
|
||||||
|
binding.root.postDelayed({ updateDots() }, remaining)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFailure() {
|
||||||
|
val msg = failureMessage()
|
||||||
|
binding.tvLockPinDots.text = msg
|
||||||
|
binding.root.postDelayed({ updateDots() }, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failureMessage(): String {
|
||||||
|
val fails = incrementFailures()
|
||||||
|
val left = MAX_ATTEMPTS - fails
|
||||||
|
return if (left <= 0) {
|
||||||
|
val secs = (LOCKOUT_MS / 1000L).toInt()
|
||||||
|
getString(R.string.unlock_locked_out, secs)
|
||||||
|
} else {
|
||||||
|
getString(R.string.unlock_attempts_remaining, left)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun verify(input: String): Boolean {
|
private fun verify(input: String): Boolean {
|
||||||
val hash = sha256(salt + input)
|
if (storedHash.isBlank()) return false
|
||||||
return hash == storedHash
|
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||||
|
return pbkdf2(input, saltBytes) == storedHash
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerBiometric() {
|
private fun triggerBiometric() {
|
||||||
@@ -145,6 +232,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
ContextCompat.getMainExecutor(this),
|
ContextCompat.getMainExecutor(this),
|
||||||
object : BiometricPrompt.AuthenticationCallback() {
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
resetFailures()
|
||||||
proceed()
|
proceed()
|
||||||
}
|
}
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
@@ -162,15 +250,46 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun proceed() {
|
private fun proceed() {
|
||||||
startActivity(Intent(this, HomeActivity::class.java))
|
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||||
finish()
|
finish()
|
||||||
|
} else {
|
||||||
|
startActivity(Intent(this, HomeActivity::class.java))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
|
// ── Brute-force tracking ──────────────────────────────────────────────────
|
||||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
||||||
|
|
||||||
override fun onBackPressed() {
|
private fun incrementFailures(): Int {
|
||||||
// Lock screen cannot be dismissed
|
val fails = lockPrefs.getInt("fail_count", 0) + 1
|
||||||
moveTaskToBack(true)
|
lockPrefs.edit()
|
||||||
|
.putInt("fail_count", fails)
|
||||||
|
.putLong("last_fail_time", System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
return fails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resetFailures() {
|
||||||
|
lockPrefs.edit().remove("fail_count").remove("last_fail_time").apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lockoutRemainingMs(): Long {
|
||||||
|
val fails = lockPrefs.getInt("fail_count", 0)
|
||||||
|
if (fails < MAX_ATTEMPTS) return 0L
|
||||||
|
val lastFail = lockPrefs.getLong("last_fail_time", 0L)
|
||||||
|
return maxOf(0L, LOCKOUT_MS - (System.currentTimeMillis() - lastFail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crypto ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun pbkdf2(input: String, salt: ByteArray): String {
|
||||||
|
val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256)
|
||||||
|
return try {
|
||||||
|
val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||||
|
Base64.encodeToString(hash, Base64.NO_WRAP)
|
||||||
|
} finally {
|
||||||
|
spec.clearPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||||
val securitySet = prefs.getString("security_method", null) != null
|
val securitySet = prefs.getString("security_method", null) != null
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials()
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||||
|
|
||||||
val target = when {
|
val target = when {
|
||||||
!onboardingDone -> OnboardingActivity::class.java
|
!onboardingDone -> OnboardingActivity::class.java
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ class BmlLoginFlow {
|
|||||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||||
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
|
private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||||
private val APP_VERSION = "2.1.43.345"
|
private val APP_VERSION = "2.1.44.348"
|
||||||
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
@@ -58,9 +58,27 @@ class BmlLoginFlow {
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Full login: returns a BmlSession and the account list. */
|
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||||
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
|
private var codeVerifier: String = ""
|
||||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
|
private var codeChallenge: String = ""
|
||||||
|
private var deviceId: String = ""
|
||||||
|
|
||||||
|
/** Profiles returned by the last successful [login] call. */
|
||||||
|
var lastProfiles: List<BmlProfile> = emptyList()
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ─── Login ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs web authentication (login + TOTP) and returns the list of available profiles.
|
||||||
|
* Call [activateProfile] for each profile to obtain an access token + accounts.
|
||||||
|
*/
|
||||||
|
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
|
||||||
|
codeVerifier = generateCodeVerifier()
|
||||||
|
codeChallenge = generateCodeChallenge(codeVerifier)
|
||||||
|
deviceId = generateDeviceId()
|
||||||
|
|
||||||
|
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login")
|
Request.Builder().url("$BASE_URL/web/login")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
@@ -69,8 +87,11 @@ class BmlLoginFlow {
|
|||||||
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
|
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
|
||||||
|
|
||||||
// Step 2: POST credentials
|
// Step 2: POST credentials
|
||||||
val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}"""
|
val loginBody = JSONObject().apply {
|
||||||
.toRequestBody("application/json".toMediaType())
|
put("username", username)
|
||||||
|
put("password", password)
|
||||||
|
put("code", "")
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
val loginResp = client.newCall(
|
val loginResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
|
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
|
||||||
.header("X-XSRF-TOKEN", xsrf)
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
@@ -79,18 +100,19 @@ class BmlLoginFlow {
|
|||||||
loginResp.close()
|
loginResp.close()
|
||||||
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
||||||
|
|
||||||
// Step 3: GET 2FA page (refreshes blaze_session)
|
// Step 3: GET 2FA page (refreshes session cookies)
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login/2fa")
|
Request.Builder().url("$BASE_URL/web/login/2fa")
|
||||||
.header("X-XSRF-TOKEN", xsrf)
|
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
val xsrf2 = xsrfToken() ?: xsrf
|
val xsrf2 = xsrfToken() ?: xsrf
|
||||||
|
|
||||||
// Step 4: POST OTP
|
// Step 4: POST TOTP
|
||||||
val otp = Totp.generate(otpSeed)
|
val otp = Totp.generate(otpSeed)
|
||||||
val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}"""
|
val twoFaBody = JSONObject().apply {
|
||||||
.toRequestBody("application/json".toMediaType())
|
put("code", otp)
|
||||||
|
put("channel", "authenticator")
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
val twoFaResp = client.newCall(
|
val twoFaResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
|
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
|
||||||
.header("X-XSRF-TOKEN", xsrf2)
|
.header("X-XSRF-TOKEN", xsrf2)
|
||||||
@@ -99,18 +121,161 @@ class BmlLoginFlow {
|
|||||||
twoFaResp.close()
|
twoFaResp.close()
|
||||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
||||||
|
|
||||||
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
|
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
|
||||||
client.newCall(
|
// single-profile accounts skip the picker and redirect straight to /web/redirect with
|
||||||
|
// blaze_identity already set in the response cookies.
|
||||||
|
val profileResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/profile")
|
Request.Builder().url("$BASE_URL/web/profile")
|
||||||
.header("X-XSRF-TOKEN", xsrf2)
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val profileCode = profileResp.code
|
||||||
|
val profileLocation = profileResp.header("Location") ?: ""
|
||||||
|
val profileBody = profileResp.body?.string() ?: ""
|
||||||
|
profileResp.close()
|
||||||
|
|
||||||
|
lastProfiles = if (profileCode == 302) {
|
||||||
|
// Any 302 from GET /web/profile means the server auto-activated the sole profile
|
||||||
|
// and blaze_identity is already set — no profile picker shown.
|
||||||
|
// Use username as a stable temporary profileId (unique per login); it will be
|
||||||
|
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
|
||||||
|
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
|
||||||
|
} else {
|
||||||
|
parseProfiles(profileBody)
|
||||||
|
}
|
||||||
|
return lastProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile activation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates a profile in the current web session and returns the result.
|
||||||
|
*
|
||||||
|
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
|
||||||
|
* - Business profiles (profile_type="business") require SMS/email OTP; returns
|
||||||
|
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
|
||||||
|
* [requestBusinessOtp] + [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
|
||||||
|
// Single-profile accounts: server already activated during login() and set blaze_identity.
|
||||||
|
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
|
||||||
|
if (profile.autoActivated) {
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
return BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
val xsrf = xsrfToken()
|
||||||
|
val reqBuilder = Request.Builder()
|
||||||
|
.url("$BASE_URL/web/profile/${profile.profileId}")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT)
|
||||||
|
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
|
||||||
|
val resp = client.newCall(reqBuilder.build()).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
|
||||||
|
// Profile activated — blaze_identity cookie set in response headers.
|
||||||
|
// Any 302 that isn't to the business 2FA page means success.
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
code == 302 && "/web/profile/2fa/business" in location -> {
|
||||||
|
// Business profile: server requires SMS/email OTP
|
||||||
|
val channels = fetchBusinessOtpChannels()
|
||||||
|
BmlActivationResult.NeedsBusinessOtp(channels)
|
||||||
|
}
|
||||||
|
else -> throw Exception("Profile activation failed (HTTP $code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available OTP channels for the business 2FA page.
|
||||||
|
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
|
||||||
|
*/
|
||||||
|
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val body = resp.body?.string() ?: ""
|
||||||
|
resp.close()
|
||||||
|
return parseBusinessOtpChannels(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an OTP to [channel] for business profile activation.
|
||||||
|
* Must be called before [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun requestBusinessOtp(channel: String) {
|
||||||
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("code", "")
|
||||||
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
resp.close()
|
||||||
|
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the OTP and activates the business profile.
|
||||||
|
* Returns a new [BmlSession] and accounts on success.
|
||||||
|
* @throws Exception if the OTP is invalid (retry is allowed).
|
||||||
|
*/
|
||||||
|
fun submitBusinessOtp(
|
||||||
|
channel: String,
|
||||||
|
code: String,
|
||||||
|
profile: BmlProfile,
|
||||||
|
loginTag: String
|
||||||
|
): Pair<BmlSession, List<MibAccount>> {
|
||||||
|
// Refresh XSRF token before submitting
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
|
|
||||||
// Step 6: PKCE OAuth authorize → extract auth code
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
val codeVerifier = generateCodeVerifier()
|
val body = JSONObject().apply {
|
||||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
put("code", code)
|
||||||
val deviceId = generateDeviceId()
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
|
||||||
|
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
respCode == 302 ->
|
||||||
|
throw Exception("Invalid OTP — please try again")
|
||||||
|
else ->
|
||||||
|
throw Exception("Business OTP verification failed (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OAuth + account fetch ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
|
||||||
|
* Returns a fresh [BmlSession] and the profile's accounts.
|
||||||
|
*/
|
||||||
|
private fun doOAuthAndFetchAccounts(
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String,
|
||||||
|
profileId: String
|
||||||
|
): Pair<BmlSession, List<MibAccount>> {
|
||||||
val authorizeUrl = HttpUrl.Builder()
|
val authorizeUrl = HttpUrl.Builder()
|
||||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||||
.addPathSegments("internetbanking/oauth/authorize")
|
.addPathSegments("internetbanking/oauth/authorize")
|
||||||
@@ -130,14 +295,12 @@ class BmlLoginFlow {
|
|||||||
Request.Builder().url(authorizeUrl)
|
Request.Builder().url(authorizeUrl)
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val location = authorizeResp.header("Location")
|
||||||
authorizeResp.close()
|
authorizeResp.close()
|
||||||
|
|
||||||
val location = authorizeResp.header("Location")
|
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
|
||||||
?: throw Exception("OAuth authorize did not redirect")
|
?: throw Exception("OAuth authorize did not return auth code")
|
||||||
val authCode = Uri.parse(location).getQueryParameter("code")
|
|
||||||
?: throw Exception("No auth code in OAuth redirect")
|
|
||||||
|
|
||||||
// Step 7: Exchange auth code for access token
|
|
||||||
val tokenBody = FormBody.Builder()
|
val tokenBody = FormBody.Builder()
|
||||||
.add("Device-ID", deviceId)
|
.add("Device-ID", deviceId)
|
||||||
.add("code", authCode)
|
.add("code", authCode)
|
||||||
@@ -156,22 +319,28 @@ class BmlLoginFlow {
|
|||||||
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
|
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
|
||||||
tokenResp.close()
|
tokenResp.close()
|
||||||
|
|
||||||
val tokenObj = JSONObject(tokenJson)
|
val accessToken = JSONObject(tokenJson).optString("access_token")
|
||||||
val accessToken = tokenObj.optString("access_token")
|
|
||||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||||
|
|
||||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
||||||
val accounts = fetchAccounts(session)
|
val accounts = fetchAccounts(session, loginTag, profileName, profileId)
|
||||||
return Pair(session, accounts)
|
return Pair(session, accounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchAccounts(session: BmlSession): List<MibAccount> {
|
// ─── API methods ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun fetchAccounts(
|
||||||
|
session: BmlSession,
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String = "Personal",
|
||||||
|
profileId: String = ""
|
||||||
|
): List<MibAccount> {
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
||||||
val code = resp.code
|
val code = resp.code
|
||||||
val json = resp.body?.string()
|
val json = resp.body?.string()
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
return parseDashboard(json ?: return emptyList(), "bml_${session.deviceId}")
|
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||||
@@ -319,16 +488,13 @@ class BmlLoginFlow {
|
|||||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchContacts(session: BmlSession): List<MibBeneficiary> {
|
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
||||||
val json = resp.body?.string() ?: return emptyList()
|
val json = resp.body?.string() ?: return emptyList()
|
||||||
resp.close()
|
resp.close()
|
||||||
return parseContacts(json)
|
return parseContacts(json, loginId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
|
||||||
*/
|
|
||||||
fun initiateTransfer(
|
fun initiateTransfer(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
debitAccount: String,
|
debitAccount: String,
|
||||||
@@ -365,9 +531,6 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
|
|
||||||
*/
|
|
||||||
fun confirmTransfer(
|
fun confirmTransfer(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
debitAccount: String,
|
debitAccount: String,
|
||||||
@@ -411,7 +574,8 @@ class BmlLoginFlow {
|
|||||||
BmlTransferResult(
|
BmlTransferResult(
|
||||||
success = true,
|
success = true,
|
||||||
reference = payload?.optString("reference") ?: "",
|
reference = payload?.optString("reference") ?: "",
|
||||||
timestamp = payload?.optString("timestamp") ?: ""
|
timestamp = payload?.optString("timestamp") ?: "",
|
||||||
|
message = json.optString("message")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||||
@@ -434,32 +598,6 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
|
||||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
|
||||||
return try {
|
|
||||||
val parts = narrative1.split(" ")
|
|
||||||
if (parts.size < 2) null
|
|
||||||
else {
|
|
||||||
val timePart = parts[1].take(4)
|
|
||||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
|
||||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
|
||||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
|
||||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
|
||||||
return try {
|
|
||||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
|
||||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches paginated transaction history for a BML CASA account.
|
|
||||||
* @return Pair of (transactions, totalPages)
|
|
||||||
*/
|
|
||||||
fun fetchAccountHistory(
|
fun fetchAccountHistory(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
accountId: String,
|
accountId: String,
|
||||||
@@ -510,10 +648,6 @@ class BmlLoginFlow {
|
|||||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
|
|
||||||
* Returns combined outstanding authorizations + settled statement entries.
|
|
||||||
*/
|
|
||||||
fun fetchCardHistory(
|
fun fetchCardHistory(
|
||||||
session: BmlSession,
|
session: BmlSession,
|
||||||
cardId: String,
|
cardId: String,
|
||||||
@@ -540,7 +674,6 @@ class BmlLoginFlow {
|
|||||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||||
val result = mutableListOf<Transaction>()
|
val result = mutableListOf<Transaction>()
|
||||||
|
|
||||||
// Outstanding authorizations
|
|
||||||
val authDetails = payload.optJSONObject("outstanding")
|
val authDetails = payload.optJSONObject("outstanding")
|
||||||
?.optJSONArray("CardOutStdAuthDetails")
|
?.optJSONArray("CardOutStdAuthDetails")
|
||||||
if (authDetails != null) {
|
if (authDetails != null) {
|
||||||
@@ -561,7 +694,26 @@ class BmlLoginFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settled statement entries
|
val unbilled = payload.optJSONObject("unbilled")
|
||||||
|
?.optJSONArray("CardUnbillTxnDetails")
|
||||||
|
if (unbilled != null) {
|
||||||
|
for (i in 0 until unbilled.length()) {
|
||||||
|
val item = unbilled.getJSONObject(i)
|
||||||
|
result.add(Transaction(
|
||||||
|
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
||||||
|
date = item.optString("DateTime"),
|
||||||
|
description = item.optString("TranDesc").trim(),
|
||||||
|
amount = item.optDouble("BillingAmount", 0.0),
|
||||||
|
currency = item.optString("BillingCcy", "MVR"),
|
||||||
|
counterpartyName = null,
|
||||||
|
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "BML_CARD"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val statement = payload.optJSONArray("cardstatement")
|
val statement = payload.optJSONArray("cardstatement")
|
||||||
if (statement != null) {
|
if (statement != null) {
|
||||||
for (i in 0 until statement.length()) {
|
for (i in 0 until statement.length()) {
|
||||||
@@ -584,14 +736,64 @@ class BmlLoginFlow {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun apiRequest(session: BmlSession, url: String) =
|
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||||
Request.Builder().url(url)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
|
/**
|
||||||
|
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
|
||||||
|
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
|
||||||
|
*/
|
||||||
|
private fun extractInertiaJson(html: String): String? {
|
||||||
|
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
|
||||||
|
return match.groupValues[1]
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseProfiles(html: String): List<BmlProfile> {
|
||||||
|
return try {
|
||||||
|
val json = extractInertiaJson(html) ?: html
|
||||||
|
val root = JSONObject(json)
|
||||||
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
|
val profiles = props.optJSONArray("profiles") ?: return emptyList()
|
||||||
|
(0 until profiles.length()).mapNotNull { i ->
|
||||||
|
val p = profiles.getJSONObject(i)
|
||||||
|
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
|
||||||
|
BmlProfile(
|
||||||
|
profileId = p.optString("profile_id"),
|
||||||
|
name = p.optString("name"),
|
||||||
|
type = p.optString("type"),
|
||||||
|
profileType = profileObj.optString("profile_type", "default")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
|
||||||
|
return try {
|
||||||
|
val json = extractInertiaJson(html) ?: html
|
||||||
|
val root = JSONObject(json)
|
||||||
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
|
val channels = props.optJSONArray("channels") ?: return emptyList()
|
||||||
|
(0 until channels.length()).map { i ->
|
||||||
|
val c = channels.getJSONObject(i)
|
||||||
|
BmlOtpChannel(
|
||||||
|
channel = c.optString("channel"),
|
||||||
|
description = c.optString("description"),
|
||||||
|
masked = c.optString("masked")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDashboard(
|
||||||
|
json: String,
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String = "Personal",
|
||||||
|
profileId: String = ""
|
||||||
|
): List<MibAccount> {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||||
@@ -606,13 +808,13 @@ class BmlLoginFlow {
|
|||||||
val product = item.optString("product")
|
val product = item.optString("product")
|
||||||
val accountNumber = item.optString("account")
|
val accountNumber = item.optString("account")
|
||||||
val status = item.optString("account_status", "Active")
|
val status = item.optString("account_status", "Active")
|
||||||
|
|
||||||
val internalId = item.optString("id", "")
|
val internalId = item.optString("id", "")
|
||||||
|
|
||||||
if (accountType == "CASA") {
|
if (accountType == "CASA") {
|
||||||
val available = item.optDouble("availableBalance", 0.0)
|
val available = item.optDouble("availableBalance", 0.0)
|
||||||
casaAccounts.add(MibAccount(
|
casaAccounts.add(MibAccount(
|
||||||
profileName = "Personal",
|
bank = "BML",
|
||||||
|
profileName = profileName,
|
||||||
profileType = "BML",
|
profileType = "BML",
|
||||||
accountNumber = accountNumber,
|
accountNumber = accountNumber,
|
||||||
accountBriefName = item.optString("alias"),
|
accountBriefName = item.optString("alias"),
|
||||||
@@ -625,32 +827,34 @@ class BmlLoginFlow {
|
|||||||
statusDesc = status,
|
statusDesc = status,
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = loginTag,
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
internalId = internalId
|
internalId = internalId
|
||||||
))
|
))
|
||||||
} else if (accountType == "Card") {
|
} else if (accountType == "Card") {
|
||||||
|
val isVisible = item.optBoolean("account_visible", false)
|
||||||
|
if (!isVisible) continue
|
||||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||||
if (isPrepaid) {
|
val cardBalance = item.optJSONObject("cardBalance")
|
||||||
val cardBalance = item.optJSONObject("cardBalance")
|
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||||
prepaidCards.add(MibAccount(
|
prepaidCards.add(MibAccount(
|
||||||
profileName = "Personal",
|
bank = "BML",
|
||||||
profileType = "BML_PREPAID",
|
profileName = profileName,
|
||||||
accountNumber = accountNumber,
|
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||||
accountBriefName = product,
|
accountNumber = accountNumber,
|
||||||
currencyName = currency,
|
accountBriefName = item.optString("alias").ifBlank { product },
|
||||||
accountTypeName = product,
|
currencyName = currency,
|
||||||
availableBalance = "%.2f".format(available),
|
accountTypeName = product,
|
||||||
currentBalance = "%.2f".format(cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0),
|
availableBalance = "%.2f".format(available),
|
||||||
blockedAmount = "0.00",
|
currentBalance = "%.2f".format(current),
|
||||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
blockedAmount = "0.00",
|
||||||
statusDesc = status,
|
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||||
profileImageHash = null,
|
statusDesc = status,
|
||||||
loginTag = loginTag,
|
profileImageHash = null,
|
||||||
internalId = internalId
|
loginTag = loginTag,
|
||||||
))
|
profileId = profileId,
|
||||||
} else {
|
internalId = internalId
|
||||||
// Linked debit cards have no independent balance or account link — skip
|
))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,7 +892,7 @@ class BmlLoginFlow {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseContacts(json: String): List<MibBeneficiary> {
|
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||||
@@ -709,12 +913,22 @@ class BmlLoginFlow {
|
|||||||
benefStatus = item.optString("status", "S"),
|
benefStatus = item.optString("status", "S"),
|
||||||
transferCyDesc = item.optString("currency", "MVR"),
|
transferCyDesc = item.optString("currency", "MVR"),
|
||||||
customerImgHash = null,
|
customerImgHash = null,
|
||||||
benefCategoryId = "BML"
|
benefCategoryId = "BML",
|
||||||
|
profileId = loginId
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun apiRequest(session: BmlSession, url: String) =
|
||||||
|
Request.Builder().url(url)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", APP_USER_AGENT)
|
||||||
|
.header("x-app-version", APP_VERSION)
|
||||||
|
.build()
|
||||||
|
|
||||||
private fun xsrfToken(): String? =
|
private fun xsrfToken(): String? =
|
||||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||||
|
|
||||||
@@ -741,5 +955,25 @@ class BmlLoginFlow {
|
|||||||
return bytes.joinToString("") { "%02x".format(it) }
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||||
|
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||||
|
return try {
|
||||||
|
val parts = narrative1.split(" ")
|
||||||
|
if (parts.size < 2) null
|
||||||
|
else {
|
||||||
|
val timePart = parts[1].take(4)
|
||||||
|
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||||
|
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||||
|
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||||
|
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||||
|
return try {
|
||||||
|
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||||
|
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
|
||||||
data class BmlSession(
|
data class BmlSession(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val deviceId: String
|
val deviceId: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlProfile(
|
||||||
|
val profileId: String,
|
||||||
|
val name: String,
|
||||||
|
val type: String, // "Profile" (personal) or "Business"
|
||||||
|
val profileType: String, // "default" or "business"
|
||||||
|
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BmlOtpChannel(
|
||||||
|
val channel: String,
|
||||||
|
val description: String,
|
||||||
|
val masked: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class BmlActivationResult {
|
||||||
|
data class Success(
|
||||||
|
val session: BmlSession,
|
||||||
|
val accounts: List<MibAccount>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
data class NeedsBusinessOtp(
|
||||||
|
val channels: List<BmlOtpChannel>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
}
|
||||||
|
|
||||||
data class BmlAccountValidation(
|
data class BmlAccountValidation(
|
||||||
val trnType: String, // IAT, QTR, DOT
|
val trnType: String, // IAT, QTR, DOT
|
||||||
val validationType: String, // BML, alias, MIB
|
val validationType: String, // BML, alias, MIB
|
||||||
@@ -20,6 +46,7 @@ data class BmlTransferResult(
|
|||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val reference: String = "",
|
val reference: String = "",
|
||||||
val timestamp: String = "",
|
val timestamp: String = "",
|
||||||
|
val message: String = "",
|
||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package sh.sar.basedbank.api.dhiraagu
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class DhiraaguClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
enum class CustType { RELOAD, BILL_PAY, UNSUPPORTED }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Result(val type: CustType, val ownerName: String = "")
|
||||||
|
|
||||||
|
fun validateNumber(number: String): Result {
|
||||||
|
// Step 1: fetch Easy Pay page to extract the nonce
|
||||||
|
val pageResp = client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("https://www.dhiraagu.com.mv/services/easy-pay")
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val html = pageResp.body?.string() ?: return Result(CustType.UNSUPPORTED)
|
||||||
|
pageResp.close()
|
||||||
|
|
||||||
|
val nonce = Regex("""var nonce = "([^"]+)"""").find(html)
|
||||||
|
?.groupValues?.getOrNull(1) ?: return Result(CustType.UNSUPPORTED)
|
||||||
|
|
||||||
|
// Step 2: look up number
|
||||||
|
val body = """{"number":"$number"}"""
|
||||||
|
.toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("https://www.dhiraagu.com.mv/api/sdk-dhr-webapi.ashx?website_id=CA2BB809-3A22-485B-A518-DA6B6DE653A5&sub=dhiraaguIO&act=infoUnlisted")
|
||||||
|
.post(body)
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.header("nonce", nonce)
|
||||||
|
.header("Origin", "https://dhiraagu.com.mv")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return Result(CustType.UNSUPPORTED)
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (obj.optString("respStatus") != "OK") return Result(CustType.UNSUPPORTED)
|
||||||
|
val prepaid = obj.optJSONArray("serviceDetails")
|
||||||
|
?.optJSONObject(0)
|
||||||
|
?.optString("prepaidIndicator")
|
||||||
|
val ownerName = obj.optJSONObject("accountOwnerInfo")
|
||||||
|
?.optString("name") ?: ""
|
||||||
|
val type = when (prepaid) {
|
||||||
|
"Y" -> CustType.RELOAD
|
||||||
|
"N" -> CustType.BILL_PAY
|
||||||
|
else -> CustType.UNSUPPORTED
|
||||||
|
}
|
||||||
|
Result(type, ownerName)
|
||||||
|
} catch (_: Exception) { Result(CustType.UNSUPPORTED) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.Buffer
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||||
|
import sh.sar.basedbank.api.mib.Transaction
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class FahipayLoginFlow {
|
||||||
|
|
||||||
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
|
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
|
private val UA_OKHTTP = "okhttp/4.12.0"
|
||||||
|
private val PAGE_SIZE = 15
|
||||||
|
|
||||||
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
|
private val cookieJar = object : CookieJar {
|
||||||
|
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
||||||
|
val list = cookieStore.getOrPut(url.host) { mutableListOf() }
|
||||||
|
for (c in cookies) {
|
||||||
|
list.removeAll { it.name == c.name }
|
||||||
|
list.add(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||||
|
cookieStore[url.host] ?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.cookieJar(cookieJar)
|
||||||
|
.followRedirects(false)
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Seed the cookie jar with a stored session cookie before using a persisted session. */
|
||||||
|
fun setSessionCookie(value: String) {
|
||||||
|
val host = "fahipay.mv"
|
||||||
|
val list = cookieStore.getOrPut(host) { mutableListOf() }
|
||||||
|
list.removeAll { it.name == "__Secure-sess" }
|
||||||
|
list.add(
|
||||||
|
Cookie.Builder()
|
||||||
|
.domain(host)
|
||||||
|
.name("__Secure-sess")
|
||||||
|
.value(value)
|
||||||
|
.secure()
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSessionCookieValue(): String? =
|
||||||
|
cookieStore["fahipay.mv"]?.firstOrNull { it.name == "__Secure-sess" }?.value
|
||||||
|
|
||||||
|
// Establishes the __Secure-sess cookie required for the login+OTP flow.
|
||||||
|
private fun initSession() {
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/api/app/lang/data/")
|
||||||
|
.get()
|
||||||
|
.header("User-Agent", UA_WEBVIEW)
|
||||||
|
.build()
|
||||||
|
).execute().close()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 1: POST /api/app/login/
|
||||||
|
* Returns FahipayLoginStep:
|
||||||
|
* twoFactorRequired=false + authId set → login complete, proceed
|
||||||
|
* twoFactorRequired=true + authId=null → call verifyTotp() next
|
||||||
|
*/
|
||||||
|
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
|
||||||
|
initSession()
|
||||||
|
val body = buildFormBody(
|
||||||
|
"email" to idCard,
|
||||||
|
"password" to password,
|
||||||
|
"grant_type" to "auth_id",
|
||||||
|
"lang" to "en",
|
||||||
|
"version" to "2.0.0",
|
||||||
|
"platform" to "BasedBank",
|
||||||
|
*deviceParts(deviceUuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/api/app/login/")
|
||||||
|
.post(body)
|
||||||
|
.header("User-Agent", UA_WEBVIEW)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: throw Exception("Empty login response")
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (obj.optString("type") != "success") {
|
||||||
|
throw Exception(obj.optString("msg", "Login failed — check your ID card and password"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val authId = obj.optString("authID", "").takeIf { it.isNotBlank() }
|
||||||
|
val twoFa = obj.optBoolean("two_factor_required", false)
|
||||||
|
return FahipayLoginStep(twoFactorRequired = twoFa, authId = authId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step 2 (if 2FA required): POST /api/app/otp/
|
||||||
|
* Returns authId.
|
||||||
|
*/
|
||||||
|
fun verifyTotp(code: String, deviceUuid: String): String {
|
||||||
|
val body = buildFormBody(
|
||||||
|
"code" to code,
|
||||||
|
"channel" to "totp",
|
||||||
|
"action" to "login",
|
||||||
|
"grant_type" to "auth_id",
|
||||||
|
"lang" to "en",
|
||||||
|
"version" to "2.0.0",
|
||||||
|
"platform" to "BasedBank",
|
||||||
|
*deviceParts(deviceUuid)
|
||||||
|
)
|
||||||
|
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/api/app/otp/")
|
||||||
|
.post(body)
|
||||||
|
.header("User-Agent", UA_WEBVIEW)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: throw Exception("Empty OTP response")
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (obj.optString("type") != "success") {
|
||||||
|
throw Exception(obj.optString("msg", "OTP verification failed"))
|
||||||
|
}
|
||||||
|
return obj.optString("authID").takeIf { it.isNotBlank() }
|
||||||
|
?: throw Exception("No authID in OTP response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("content-type", "multipart/form-data")
|
||||||
|
.header("User-Agent", UA_OKHTTP)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||||
|
return FahipayUserProfile(
|
||||||
|
fullName = obj.optString("fullname").trim(),
|
||||||
|
email = obj.optString("email").trim(),
|
||||||
|
mobile = obj.optString("mobile").trim(),
|
||||||
|
nid = obj.optString("nid").trim(),
|
||||||
|
profileId = obj.optString("profileID").trim(),
|
||||||
|
walletAccount = props.optString("acc", ""),
|
||||||
|
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchBalance(session: FahipaySession): Double {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("content-type", "multipart/form-data")
|
||||||
|
.header("User-Agent", UA_OKHTTP)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return 0.0
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||||
|
} catch (_: Exception) { 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
|
||||||
|
MibAccount(
|
||||||
|
bank = "FAHIPAY",
|
||||||
|
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||||
|
profileType = "FAHIPAY",
|
||||||
|
accountNumber = profile.walletAccount,
|
||||||
|
accountBriefName = "Fahipay Wallet",
|
||||||
|
currencyName = "MVR",
|
||||||
|
accountTypeName = "Digital Wallet",
|
||||||
|
availableBalance = "%.2f".format(balance),
|
||||||
|
currentBalance = "%.2f".format(balance),
|
||||||
|
blockedAmount = "0.00",
|
||||||
|
mvrBalance = "%.2f".format(balance),
|
||||||
|
statusDesc = "Active",
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = loginTag,
|
||||||
|
internalId = profile.profileId
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches paginated activity history.
|
||||||
|
* @param start offset (0-based)
|
||||||
|
* @return Pair of (transactions, total count)
|
||||||
|
*/
|
||||||
|
fun fetchHistory(
|
||||||
|
session: FahipaySession,
|
||||||
|
accountDisplayName: String,
|
||||||
|
accountNumber: String,
|
||||||
|
start: Int
|
||||||
|
): Pair<List<Transaction>, Int> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("content-type", "multipart/form-data")
|
||||||
|
.header("User-Agent", UA_OKHTTP)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val total = obj.optInt("total", 0)
|
||||||
|
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||||
|
val list = (0 until entries.length()).map { i ->
|
||||||
|
val e = entries.getJSONObject(i)
|
||||||
|
Transaction(
|
||||||
|
id = e.optString("transaction"),
|
||||||
|
date = e.optString("date"),
|
||||||
|
description = e.optString("name").trim(),
|
||||||
|
amount = e.optDouble("amount", 0.0),
|
||||||
|
currency = "MVR",
|
||||||
|
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||||
|
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "FAHIPAY",
|
||||||
|
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Pair(list, total)
|
||||||
|
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches Fahipay saved favourites for the 4 service groups.
|
||||||
|
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
|
||||||
|
* Groups with no valid entries are omitted.
|
||||||
|
*/
|
||||||
|
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||||
|
val endpoints = listOf(
|
||||||
|
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||||
|
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||||
|
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||||
|
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||||
|
)
|
||||||
|
val result = mutableListOf<FahipayContactGroup>()
|
||||||
|
for ((catId, label, page) in endpoints) {
|
||||||
|
try {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("User-Agent", UA_OKHTTP)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: continue
|
||||||
|
resp.close()
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
|
||||||
|
val groupObj = obj.optJSONObject(page) ?: continue
|
||||||
|
val contacts = mutableListOf<MibBeneficiary>()
|
||||||
|
for (key in groupObj.keys()) {
|
||||||
|
val entry = groupObj.getJSONObject(key)
|
||||||
|
val number = entry.optString("number")
|
||||||
|
val name = entry.optString("name").trim().ifBlank { number }
|
||||||
|
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||||
|
contacts.add(MibBeneficiary(
|
||||||
|
benefNo = "fp_${page}_$number",
|
||||||
|
benefName = "",
|
||||||
|
benefNickName = name,
|
||||||
|
benefAccount = number,
|
||||||
|
benefType = "FAHIPAY",
|
||||||
|
bankColor = "#FF6B00",
|
||||||
|
benefBankName = label,
|
||||||
|
bankCode = "",
|
||||||
|
benefStatus = "",
|
||||||
|
transferCyDesc = "",
|
||||||
|
customerImgHash = null,
|
||||||
|
benefCategoryId = catId,
|
||||||
|
profileId = ""
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||||
|
"device[available]" to "true",
|
||||||
|
"device[platform]" to "Android",
|
||||||
|
"device[uuid]" to deviceUuid,
|
||||||
|
"device[model]" to Build.MODEL,
|
||||||
|
"device[manufacturer]" to Build.MANUFACTURER,
|
||||||
|
"device[isVirtual]" to "false",
|
||||||
|
"device[serial]" to "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
|
||||||
|
* which is what the Fahipay server requires.
|
||||||
|
*/
|
||||||
|
private fun buildFormBody(vararg parts: Pair<String, String>): RequestBody {
|
||||||
|
val boundary = java.util.UUID.randomUUID().toString()
|
||||||
|
val buf = Buffer()
|
||||||
|
for ((name, value) in parts) {
|
||||||
|
val valueBytes = value.toByteArray(Charsets.UTF_8)
|
||||||
|
buf.writeUtf8("--$boundary\r\n")
|
||||||
|
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
|
||||||
|
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
|
||||||
|
buf.writeUtf8("\r\n")
|
||||||
|
buf.write(valueBytes)
|
||||||
|
buf.writeUtf8("\r\n")
|
||||||
|
}
|
||||||
|
buf.writeUtf8("--$boundary--\r\n")
|
||||||
|
val snapshot = buf.readByteString()
|
||||||
|
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
|
||||||
|
return object : RequestBody() {
|
||||||
|
override fun contentType() = mediaType
|
||||||
|
override fun contentLength() = snapshot.size.toLong()
|
||||||
|
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun generateDeviceUuid(): String {
|
||||||
|
val bytes = ByteArray(8)
|
||||||
|
SecureRandom().nextBytes(bytes)
|
||||||
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
data class FahipaySession(
|
||||||
|
val authId: String,
|
||||||
|
val sessionCookie: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FahipayUserProfile(
|
||||||
|
val fullName: String,
|
||||||
|
val email: String,
|
||||||
|
val mobile: String,
|
||||||
|
val nid: String,
|
||||||
|
val profileId: String,
|
||||||
|
val walletAccount: String,
|
||||||
|
val linkedAccounts: String // raw JSON of props.accs, for transfer use
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FahipayLoginStep(
|
||||||
|
val twoFactorRequired: Boolean,
|
||||||
|
val authId: String? = null // non-null when 2FA not required
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FahipayContactGroup(
|
||||||
|
val categoryId: String,
|
||||||
|
val label: String,
|
||||||
|
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary>
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class OoredooClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
enum class CustType { PRE, POST, HYBRID, UNSUPPORTED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an Ooredoo number and returns which services are supported.
|
||||||
|
* @param number 7-digit Maldivian phone number (without country code)
|
||||||
|
*/
|
||||||
|
fun validateNumber(number: String): CustType {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("https://www.ooredoo.mv/ooredoo-prod/QuickPayPackage/v1/numberTypeValidation?action=cust_details&msisdn=960$number")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return CustType.UNSUPPORTED
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val custType = JSONObject(json)
|
||||||
|
.optJSONObject("data")
|
||||||
|
?.optString("custType")
|
||||||
|
?.takeIf { it.isNotBlank() && it != "null" }
|
||||||
|
when (custType) {
|
||||||
|
"PRE" -> CustType.PRE
|
||||||
|
"POST" -> CustType.POST
|
||||||
|
"HYBRID" -> CustType.HYBRID
|
||||||
|
else -> CustType.UNSUPPORTED
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { CustType.UNSUPPORTED }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class SessionExpiredException : Exception("MIB session expired")
|
class SessionExpiredException : Exception("MIB session expired")
|
||||||
|
|
||||||
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||||
|
|
||||||
private val TAG = "MibLoginFlow"
|
|
||||||
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
|
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
|
||||||
|
|
||||||
/** The active session after a successful login, usable for subsequent WebView requests. */
|
/** The active session after a successful login, usable for subsequent WebView requests. */
|
||||||
@@ -28,10 +29,11 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
||||||
|
|
||||||
// Stored after login so the session can be silently recovered on 419
|
// Stored after login so the session can be silently recovered on 419
|
||||||
|
@Volatile private var loginId: String = ""
|
||||||
@Volatile private var storedUsername: String? = null
|
@Volatile private var storedUsername: String? = null
|
||||||
@Volatile private var storedPasswordHash: String? = null
|
@Volatile private var storedPasswordHash: String? = null
|
||||||
@Volatile private var storedOtpSeed: String? = null
|
@Volatile private var storedOtpSeed: String? = null
|
||||||
private var inRelogin = false
|
private val inRelogin = AtomicBoolean(false)
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
@@ -57,15 +59,15 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
* Returns list of accounts from all profiles on success.
|
* Returns list of accounts from all profiles on success.
|
||||||
*/
|
*/
|
||||||
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
||||||
|
loginId = username
|
||||||
storedUsername = username
|
storedUsername = username
|
||||||
storedPasswordHash = passwordHash
|
storedPasswordHash = passwordHash
|
||||||
storedOtpSeed = otpSeed
|
storedOtpSeed = otpSeed
|
||||||
val appId = getOrCreateAppId()
|
val appId = getOrCreateAppId()
|
||||||
val key1 = prefs.getString("mib_key1_$username", null)
|
val keys = credentialStore.loadMibKeys(loginId)
|
||||||
val key2 = prefs.getString("mib_key2_$username", null)
|
|
||||||
|
|
||||||
return if (key1 != null && key2 != null) {
|
return if (keys != null) {
|
||||||
regularLogin(username, passwordHash, appId, key1, key2)
|
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
||||||
} else {
|
} else {
|
||||||
firstTimeRegistration(username, passwordHash, otpSeed, appId)
|
firstTimeRegistration(username, passwordHash, otpSeed, appId)
|
||||||
}
|
}
|
||||||
@@ -106,7 +108,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
||||||
val key1 = keyData.getString("key1")
|
val key1 = keyData.getString("key1")
|
||||||
val key2 = keyData.getString("key2")
|
val key2 = keyData.getString("key2")
|
||||||
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
|
credentialStore.saveMibKeys(loginId, key1, key2)
|
||||||
|
|
||||||
return regularLogin(username, passwordHash, appId, key1, key2)
|
return regularLogin(username, passwordHash, appId, key1, key2)
|
||||||
}
|
}
|
||||||
@@ -136,10 +138,52 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val profiles = parseProfiles(loginResp)
|
val profiles = parseProfiles(loginResp)
|
||||||
|
|
||||||
lastSession = session2
|
lastSession = session2
|
||||||
lastProfiles = profiles
|
lastProfiles = profiles // keep ALL profiles so settings can show them all
|
||||||
return fetchAllProfiles(session2, profiles, "mib_$username")
|
|
||||||
|
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
|
||||||
|
|
||||||
|
// When the server already selected the profile and returned balances in A41
|
||||||
|
// (single-profile case: profileSelected=true), use those accounts directly
|
||||||
|
// without making an extra P47 call (which the server ignores or rejects).
|
||||||
|
if (loginResp.optBoolean("profileSelected", false)) {
|
||||||
|
val a41Balances = loginResp.optJSONArray("accountBalance")
|
||||||
|
if (a41Balances != null && a41Balances.length() > 0) {
|
||||||
|
val selectedId = loginResp.optString("selectedProfileId")
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == selectedId }
|
||||||
|
?: profiles.firstOrNull()
|
||||||
|
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
|
||||||
|
val allAccounts = mutableListOf<MibAccount>()
|
||||||
|
for (i in 0 until a41Balances.length()) {
|
||||||
|
val a = a41Balances.getJSONObject(i)
|
||||||
|
allAccounts.add(
|
||||||
|
MibAccount(
|
||||||
|
bank = "MIB",
|
||||||
|
profileName = profile.name,
|
||||||
|
profileType = profile.profileType,
|
||||||
|
cifType = profile.cifType,
|
||||||
|
accountNumber = a.optString("accountNumber"),
|
||||||
|
accountBriefName = a.optString("accountBriefName"),
|
||||||
|
currencyName = a.optString("currencyName"),
|
||||||
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
|
availableBalance = a.optString("availableBalance"),
|
||||||
|
currentBalance = a.optString("currentBalance"),
|
||||||
|
blockedAmount = a.optString("blockedAmount"),
|
||||||
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
|
statusDesc = a.optString("statusDesc"),
|
||||||
|
profileImageHash = profile.customerImage,
|
||||||
|
loginTag = "mib_$username",
|
||||||
|
profileId = profile.profileId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return allAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
|
||||||
|
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
@@ -189,7 +233,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
val response = try {
|
val response = try {
|
||||||
sendRequest(session, data, sfunc)
|
sendRequest(session, data, sfunc)
|
||||||
} catch (e: SessionExpiredException) {
|
} catch (e: SessionExpiredException) {
|
||||||
if (inRelogin) throw e
|
if (inRelogin.get()) throw e
|
||||||
"" // fall through to recovery below
|
"" // fall through to recovery below
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,22 +242,27 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
(response.trimStart().startsWith("{") &&
|
(response.trimStart().startsWith("{") &&
|
||||||
JSONObject(response).optString("reasonCode") == "505")
|
JSONObject(response).optString("reasonCode") == "505")
|
||||||
|
|
||||||
if (isExpired && !inRelogin) {
|
if (isExpired && inRelogin.compareAndSet(false, true)) {
|
||||||
val u = storedUsername ?: throw SessionExpiredException()
|
try {
|
||||||
val ph = storedPasswordHash ?: throw SessionExpiredException()
|
val u = storedUsername ?: throw SessionExpiredException()
|
||||||
val os = storedOtpSeed ?: throw SessionExpiredException()
|
val ph = storedPasswordHash ?: throw SessionExpiredException()
|
||||||
inRelogin = true
|
val os = storedOtpSeed ?: throw SessionExpiredException()
|
||||||
try { login(u, ph, os) } finally { inRelogin = false }
|
login(u, ph, os)
|
||||||
val newSession = lastSession ?: throw SessionExpiredException()
|
val newSession = lastSession ?: throw SessionExpiredException()
|
||||||
onSessionRefreshed?.invoke(newSession, lastProfiles)
|
onSessionRefreshed?.invoke(newSession, lastProfiles)
|
||||||
// Refresh nonce/xxid in the payload for the retry
|
// Refresh nonce/xxid in the payload for the retry
|
||||||
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
|
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
|
||||||
data.put("appId", newSession.appId)
|
data.put("appId", newSession.appId)
|
||||||
data.put("sodium", MibNonce.randomSodium())
|
data.put("sodium", MibNonce.randomSodium())
|
||||||
data.put("xxid", newSession.xxid)
|
data.put("xxid", newSession.xxid)
|
||||||
val retryResponse = sendRequest(newSession, data, sfunc)
|
val retryResponse = sendRequest(newSession, data, sfunc)
|
||||||
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
|
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
|
||||||
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
|
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
|
||||||
|
} finally {
|
||||||
|
inRelogin.set(false)
|
||||||
|
}
|
||||||
|
} else if (isExpired) {
|
||||||
|
throw SessionExpiredException()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
||||||
@@ -266,8 +315,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
val a = accountBalances.getJSONObject(i)
|
val a = accountBalances.getJSONObject(i)
|
||||||
allAccounts.add(
|
allAccounts.add(
|
||||||
MibAccount(
|
MibAccount(
|
||||||
|
bank = "MIB",
|
||||||
profileName = profile.name,
|
profileName = profile.name,
|
||||||
profileType = profile.profileType,
|
profileType = profile.profileType,
|
||||||
|
cifType = profile.cifType,
|
||||||
accountNumber = a.optString("accountNumber"),
|
accountNumber = a.optString("accountNumber"),
|
||||||
accountBriefName = a.optString("accountBriefName"),
|
accountBriefName = a.optString("accountBriefName"),
|
||||||
currencyName = a.optString("currencyName"),
|
currencyName = a.optString("currencyName"),
|
||||||
@@ -384,11 +435,11 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||||
|
|
||||||
private fun getOrCreateAppId(): String {
|
private fun getOrCreateAppId(): String {
|
||||||
var id = prefs.getString("mib_app_id", null)
|
var id = credentialStore.loadMibAppId(loginId)
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||||
prefs.edit().putString("mib_app_id", id).apply()
|
credentialStore.saveMibAppId(loginId, id)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ data class MibProfile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class MibAccount(
|
data class MibAccount(
|
||||||
|
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||||
val profileName: String,
|
val profileName: String,
|
||||||
val profileType: String,
|
val profileType: String,
|
||||||
|
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
|
||||||
val accountNumber: String,
|
val accountNumber: String,
|
||||||
val accountBriefName: String,
|
val accountBriefName: String,
|
||||||
val currencyName: String,
|
val currencyName: String,
|
||||||
@@ -82,7 +84,8 @@ data class Transaction(
|
|||||||
val reference: String?,
|
val reference: String?,
|
||||||
val accountNumber: String,
|
val accountNumber: String,
|
||||||
val accountDisplayName: String,
|
val accountDisplayName: String,
|
||||||
val source: String // "MIB", "BML", "BML_CARD"
|
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||||
|
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibFinanceDeal(
|
data class MibFinanceDeal(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.mib
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.util.CacheEncryption
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object TransactionCache {
|
object TransactionCache {
|
||||||
@@ -21,8 +22,9 @@ object TransactionCache {
|
|||||||
put("accountNumber", t.accountNumber)
|
put("accountNumber", t.accountNumber)
|
||||||
put("accountDisplayName", t.accountDisplayName)
|
put("accountDisplayName", t.accountDisplayName)
|
||||||
put("source", t.source)
|
put("source", t.source)
|
||||||
|
if (t.iconUrl != null) put("iconUrl", t.iconUrl)
|
||||||
})
|
})
|
||||||
File(context.cacheDir, "tx_$key.json").writeText(arr.toString())
|
File(context.cacheDir, "tx_$key.json").writeText(CacheEncryption.encrypt(arr.toString()))
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ object TransactionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context, key: String): List<Transaction> = try {
|
fun load(context: Context, key: String): List<Transaction> = try {
|
||||||
val arr = JSONArray(File(context.cacheDir, "tx_$key.json").readText())
|
val arr = JSONArray(CacheEncryption.decrypt(File(context.cacheDir, "tx_$key.json").readText()))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
Transaction(
|
Transaction(
|
||||||
@@ -45,7 +47,8 @@ object TransactionCache {
|
|||||||
reference = o.optString("reference").takeIf { it.isNotBlank() },
|
reference = o.optString("reference").takeIf { it.isNotBlank() },
|
||||||
accountNumber = o.optString("accountNumber"),
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountDisplayName = o.optString("accountDisplayName"),
|
accountDisplayName = o.optString("accountDisplayName"),
|
||||||
source = o.optString("source")
|
source = o.optString("source"),
|
||||||
|
iconUrl = o.optString("iconUrl").takeIf { it.isNotBlank() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
import sh.sar.basedbank.api.mib.Transaction
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||||
@@ -20,7 +21,8 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AccountHistoryAdapter(
|
class AccountHistoryAdapter(
|
||||||
private val account: MibAccount
|
private val account: MibAccount,
|
||||||
|
private val display: AccountHistoryDisplay
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
@@ -31,7 +33,21 @@ class AccountHistoryAdapter(
|
|||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
private var lastInsertedDateKey = ""
|
private var lastInsertedDateKey = ""
|
||||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
|
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyItemChanged(0) // refresh header card
|
||||||
|
// refresh all transaction rows
|
||||||
|
for (i in displayItems.indices) {
|
||||||
|
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
@@ -41,6 +57,14 @@ class AccountHistoryAdapter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateIconUrl(url: String, bitmap: Bitmap) {
|
||||||
|
iconUrlCache[url] = bitmap
|
||||||
|
displayItems.forEachIndexed { i, item ->
|
||||||
|
if (item is Item.Trx && item.transaction.iconUrl == url)
|
||||||
|
notifyItemChanged(i + 1) // +1 for account header at position 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var _showLoadingFooter = false
|
private var _showLoadingFooter = false
|
||||||
var showLoadingFooter: Boolean
|
var showLoadingFooter: Boolean
|
||||||
get() = _showLoadingFooter
|
get() = _showLoadingFooter
|
||||||
@@ -127,7 +151,7 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is HeaderVH -> holder.bind(account)
|
is HeaderVH -> holder.bind(display)
|
||||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
||||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -136,32 +160,20 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(acc: MibAccount) {
|
fun bind(d: AccountHistoryDisplay) {
|
||||||
b.tvHeaderAccountName.text = acc.accountBriefName
|
b.tvHeaderAccountName.text = d.name
|
||||||
b.tvHeaderAccountNumber.text = acc.accountNumber
|
b.tvHeaderAccountNumber.text = d.number
|
||||||
b.tvHeaderPillBank.text = if (acc.profileType.startsWith("BML")) "BML" else "MIB"
|
b.tvHeaderPillBank.text = d.bankPill
|
||||||
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
|
b.tvHeaderPillType.text = d.typeLabel
|
||||||
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
|
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
|
||||||
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
|
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
|
||||||
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
|
if (d.blockedBalance != null) {
|
||||||
if (blocked > 0.0) {
|
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
|
||||||
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
|
|
||||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
b.llHeaderBlocked.visibility = View.GONE
|
b.llHeaderBlocked.visibility = View.GONE
|
||||||
}
|
}
|
||||||
}
|
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
|
|
||||||
private fun friendlyType(raw: String): String {
|
|
||||||
val u = raw.trim().uppercase()
|
|
||||||
return when {
|
|
||||||
u.contains("SAVING") -> "Savings"
|
|
||||||
u.contains("CURRENT") -> "Current"
|
|
||||||
u.contains("WADIAH") -> "Islamic"
|
|
||||||
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
|
|
||||||
u.contains("PREPAID") -> "Prepaid"
|
|
||||||
else -> raw.trim().take(12)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +190,10 @@ class AccountHistoryAdapter(
|
|||||||
val name = trx.counterpartyName ?: trx.description
|
val name = trx.counterpartyName ?: trx.description
|
||||||
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
val bitmap = trx.counterpartyName?.let { imageCache[it] }
|
val iconBitmap = trx.iconUrl?.let { iconUrlCache[it] }
|
||||||
|
val contactBitmap = if (iconBitmap == null) trx.counterpartyName?.let { imageCache[it] } else null
|
||||||
|
val bitmap = iconBitmap ?: contactBitmap
|
||||||
|
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
|
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
|
||||||
rd.isCircular = true
|
rd.isCircular = true
|
||||||
@@ -192,7 +207,8 @@ class AccountHistoryAdapter(
|
|||||||
b.fvAvatar.background = circle
|
b.fvAvatar.background = circle
|
||||||
b.tvInitial.visibility = View.VISIBLE
|
b.tvInitial.visibility = View.VISIBLE
|
||||||
b.tvInitial.text = initial
|
b.tvInitial.text = initial
|
||||||
if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
|
if (trx.iconUrl != null) onIconUrlNeeded?.invoke(trx.iconUrl)
|
||||||
|
else if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
|
||||||
}
|
}
|
||||||
b.tvDescription.text = trx.description
|
b.tvDescription.text = trx.description
|
||||||
|
|
||||||
@@ -206,12 +222,17 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
b.tvDate.text = formatTime(trx.date)
|
b.tvDate.text = formatTime(trx.date)
|
||||||
|
|
||||||
val sign = if (isCredit) "+" else "-"
|
if (hideAmounts) {
|
||||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||||
b.tvAmount.setTextColor(
|
} else {
|
||||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
val sign = if (isCredit) "+" else "-"
|
||||||
)
|
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||||
|
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||||
|
b.tvAmount.setTextColor(
|
||||||
|
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
b.root.setOnClickListener { showDetail(trx) }
|
b.root.setOnClickListener { showDetail(trx) }
|
||||||
}
|
}
|
||||||
@@ -277,6 +298,11 @@ class AccountHistoryAdapter(
|
|||||||
return FULL_DATE_FMT.format(date)
|
return FULL_DATE_FMT.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
fun sourceColor(source: String) = when (source) {
|
fun sourceColor(source: String) = when (source) {
|
||||||
"MIB" -> "#FE860E"
|
"MIB" -> "#FE860E"
|
||||||
"BML", "BML_CARD" -> "#0066A1"
|
"BML", "BML_CARD" -> "#0066A1"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
@@ -8,6 +10,8 @@ import android.util.Base64
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -15,20 +19,18 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
import sh.sar.basedbank.api.mib.Transaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryParser
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
import java.text.SimpleDateFormat
|
import sh.sar.basedbank.util.HistoryFetcher
|
||||||
import java.util.Calendar
|
import sh.sar.basedbank.util.MerchantIconCache
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class AccountHistoryFragment : Fragment() {
|
class AccountHistoryFragment : Fragment() {
|
||||||
|
|
||||||
@@ -38,18 +40,13 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
|
|
||||||
private lateinit var adapter: AccountHistoryAdapter
|
private lateinit var adapter: AccountHistoryAdapter
|
||||||
private lateinit var account: MibAccount
|
private lateinit var account: MibAccount
|
||||||
|
private lateinit var fetcher: HistoryFetcher
|
||||||
|
|
||||||
private val allTransactions = mutableListOf<Transaction>()
|
private val allTransactions = mutableListOf<Transaction>()
|
||||||
private var searchQuery = ""
|
private var searchQuery = ""
|
||||||
private var firstPageDone = false
|
private var firstPageDone = false
|
||||||
private val pendingImageNames = mutableSetOf<String>()
|
private val pendingImageNames = mutableSetOf<String>()
|
||||||
|
private val pendingIconUrls = mutableSetOf<String>()
|
||||||
// Pagination state
|
|
||||||
private var mibNextStart = 1
|
|
||||||
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
|
|
||||||
private var bmlNextPage = 1
|
|
||||||
private var bmlTotalPages = -1
|
|
||||||
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
|
|
||||||
private var isLoading = false
|
private var isLoading = false
|
||||||
private val pageSize = 10
|
private val pageSize = 10
|
||||||
|
|
||||||
@@ -71,11 +68,29 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
||||||
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
||||||
|
fetcher = HistoryFetcher(account)
|
||||||
|
|
||||||
adapter = AccountHistoryAdapter(account)
|
val historyDisplay = AccountHistoryParser.from(account) ?: return
|
||||||
|
adapter = AccountHistoryAdapter(account, historyDisplay)
|
||||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
|
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||||
|
adapter.onTransferClick = { acc ->
|
||||||
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||||
|
}
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||||
if (dy <= 0 || isLoading) return
|
if (dy <= 0 || isLoading) return
|
||||||
@@ -92,11 +107,10 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
override fun afterTextChanged(s: Editable?) {
|
override fun afterTextChanged(s: Editable?) {
|
||||||
searchQuery = s?.toString()?.trim() ?: ""
|
searchQuery = s?.toString()?.trim() ?: ""
|
||||||
filterAndDisplay()
|
filterAndDisplay()
|
||||||
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
|
if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load cache immediately, then fetch fresh data in background
|
|
||||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
allTransactions.addAll(cached)
|
allTransactions.addAll(cached)
|
||||||
@@ -121,17 +135,8 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isMib() = !account.profileType.startsWith("BML")
|
|
||||||
private fun isBmlCard() = account.profileType == "BML_PREPAID"
|
|
||||||
|
|
||||||
private fun hasMore(): Boolean = when {
|
|
||||||
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
|
||||||
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
|
|
||||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
if (isLoading || !hasMore()) return
|
if (isLoading || !fetcher.hasMore()) return
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
if (firstPageDone && allTransactions.isNotEmpty()) {
|
if (firstPageDone && allTransactions.isNotEmpty()) {
|
||||||
@@ -141,54 +146,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
|
val transactions = fetcher.fetchNextPage(app, pageSize)
|
||||||
when {
|
|
||||||
isMib() -> {
|
|
||||||
val session = app.mibSession ?: return@withContext emptyList()
|
|
||||||
app.mibMutex.withLock {
|
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
|
||||||
session = session,
|
|
||||||
accountNo = account.accountNumber,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
start = mibNextStart,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
if (total > 0) mibTotalCount = total
|
|
||||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isBmlCard() -> {
|
|
||||||
val session = app.bmlSession ?: return@withContext emptyList()
|
|
||||||
val cal = Calendar.getInstance()
|
|
||||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
|
||||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
|
||||||
cardMonthOffset++
|
|
||||||
BmlLoginFlow().fetchCardHistory(
|
|
||||||
session = session,
|
|
||||||
cardId = account.internalId,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
accountNumber = account.accountNumber,
|
|
||||||
month = month
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val session = app.bmlSession ?: return@withContext emptyList()
|
|
||||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
|
||||||
session = session,
|
|
||||||
accountId = account.internalId,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
accountNumber = account.accountNumber,
|
|
||||||
page = bmlNextPage
|
|
||||||
)
|
|
||||||
if (totalPages > 0) bmlTotalPages = totalPages
|
|
||||||
bmlNextPage++
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
@@ -205,7 +163,6 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
||||||
if (searchQuery.isBlank()) {
|
if (searchQuery.isBlank()) {
|
||||||
// Append incrementally to preserve scroll position
|
|
||||||
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
adapter.appendTransactions(sorted)
|
adapter.appendTransactions(sorted)
|
||||||
binding.emptyView.visibility = View.GONE
|
binding.emptyView.visibility = View.GONE
|
||||||
@@ -215,7 +172,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
} else {
|
} else {
|
||||||
adapter.showLoadingFooter = false
|
adapter.showLoadingFooter = false
|
||||||
}
|
}
|
||||||
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
|
if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
|
||||||
} else {
|
} else {
|
||||||
adapter.showLoadingFooter = false
|
adapter.showLoadingFooter = false
|
||||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||||
@@ -233,7 +190,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.mibSession ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
@@ -247,6 +204,28 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadMerchantIcon(url: String) {
|
||||||
|
if (!pendingIconUrls.add(url)) return
|
||||||
|
val cached = MerchantIconCache.load(requireContext(), url)
|
||||||
|
if (cached != null) {
|
||||||
|
binding.recyclerView.post { adapter.updateIconUrl(url, cached) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||||
|
val bytes = response.body?.bytes() ?: return@launch
|
||||||
|
response.close()
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
|
MerchantIconCache.save(requireContext(), url, bitmap)
|
||||||
|
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
pendingIconUrls.remove(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package sh.sar.basedbank.ui.home
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.drawable.GradientDrawable
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
@@ -13,17 +11,22 @@ import androidx.recyclerview.widget.RecyclerView
|
|||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||||
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
|
||||||
class AccountsAdapter(
|
class AccountsAdapter(
|
||||||
accounts: List<MibAccount>,
|
accounts: List<MibAccount>,
|
||||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
private val onAccountClick: (MibAccount) -> Unit = {}
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class SectionTitle(val label: String, val chip: String) : Item()
|
data class SectionTitle(val label: String) : Item()
|
||||||
data class Account(val account: MibAccount) : Item()
|
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||||
data class Card(val account: MibAccount) : Item()
|
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
||||||
@@ -34,18 +37,45 @@ class AccountsAdapter(
|
|||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
val regular = accounts.filter { it.profileType != "BML_PREPAID" }
|
if (hideAmounts == hide) return
|
||||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" }
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
if (regular.isNotEmpty()) {
|
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
||||||
add(Item.SectionTitle("Accounts", ""))
|
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||||
regular.forEach { add(Item.Account(it)) }
|
val nonCards = displayed.filter { !it.second.isCard }
|
||||||
|
val cards = displayed.filter { it.second.isCard }
|
||||||
|
|
||||||
|
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
|
||||||
|
for ((acc, display) in nonCards) {
|
||||||
|
val title = sectionTitle(acc)
|
||||||
|
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
|
||||||
}
|
}
|
||||||
if (prepaid.isNotEmpty()) {
|
for ((title, group) in groups) {
|
||||||
add(Item.SectionTitle("Cards", "BML"))
|
add(Item.SectionTitle(title))
|
||||||
prepaid.forEach { add(Item.Card(it)) }
|
group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cards.isNotEmpty()) {
|
||||||
|
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
||||||
|
cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sectionTitle(account: MibAccount): String {
|
||||||
|
val bankName = when (account.bank) {
|
||||||
|
"BML" -> "Bank of Maldives"
|
||||||
|
"FAHIPAY" -> "Fahipay"
|
||||||
|
"MIB" -> "Maldives Islamic Bank"
|
||||||
|
else -> account.bank
|
||||||
|
}
|
||||||
|
val profileLabel = when (account.bank) {
|
||||||
|
"MIB" -> account.cifType.ifBlank { account.profileName }
|
||||||
|
else -> account.profileName
|
||||||
|
}
|
||||||
|
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
@@ -57,51 +87,40 @@ class AccountsAdapter(
|
|||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
val inflater = LayoutInflater.from(parent.context)
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
return when (viewType) {
|
return when (viewType) {
|
||||||
TYPE_HEADER -> SectionViewHolder(ItemProfileHeaderBinding.inflate(inflater, parent, false))
|
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||||
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
||||||
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (val item = items[position]) {
|
when (val item = items[position]) {
|
||||||
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
||||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
|
is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
|
||||||
is Item.Card -> (holder as CardViewHolder).bind(item.account)
|
is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = items.size
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
private inner class SectionViewHolder(private val binding: ItemProfileHeaderBinding) :
|
private inner class SectionViewHolder(private val binding: ItemDateHeaderBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(item: Item.SectionTitle) {
|
fun bind(item: Item.SectionTitle) {
|
||||||
binding.tvProfileName.text = item.label
|
binding.tvDateHeader.text = item.label
|
||||||
if (item.chip.isNotEmpty()) {
|
|
||||||
binding.tvProfileType.text = item.chip
|
|
||||||
binding.tvProfileType.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.tvProfileType.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(account: MibAccount) {
|
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||||
binding.tvAccountName.text = account.accountBriefName
|
binding.tvAccountName.text = display.name
|
||||||
binding.tvAccountNumber.text = account.accountNumber
|
binding.tvAccountNumber.text = display.number
|
||||||
binding.tvPillBank.text = if (account.profileType.startsWith("BML")) "BML" else "MIB"
|
binding.tvAccountType.text = display.typeLabel
|
||||||
binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
|
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
binding.tvPillProfile.text = when (account.profileType) {
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
"0" -> "Personal"
|
|
||||||
"1" -> "Business"
|
|
||||||
else -> account.profileName
|
|
||||||
}
|
|
||||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
copyToClipboard(it.context, account.accountNumber)
|
copyToClipboard(it.context, display.number)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,28 +128,22 @@ class AccountsAdapter(
|
|||||||
|
|
||||||
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(account: MibAccount) {
|
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||||
val brand = cardBrand(account.accountTypeName)
|
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
|
||||||
binding.tvCardBrand.text = brand.label
|
binding.tvCardName.text = display.name
|
||||||
binding.tvCardBrand.background = GradientDrawable().apply {
|
binding.tvCardNumber.text = display.number
|
||||||
shape = GradientDrawable.RECTANGLE
|
binding.tvCardProduct.text = display.typeLabel
|
||||||
cornerRadius = 100f
|
|
||||||
setColor(Color.parseColor(brand.color))
|
|
||||||
}
|
|
||||||
binding.tvCardName.text = account.accountBriefName
|
|
||||||
binding.tvCardNumber.text = account.accountNumber
|
|
||||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||||
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
|
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
|
if (display.statusLabel != null) {
|
||||||
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
binding.tvCardStatus.text = display.statusLabel
|
||||||
if (isActive) {
|
|
||||||
binding.tvCardStatus.visibility = View.GONE
|
|
||||||
binding.root.alpha = 1f
|
|
||||||
} else {
|
|
||||||
binding.tvCardStatus.text = account.statusDesc
|
|
||||||
binding.tvCardStatus.visibility = View.VISIBLE
|
binding.tvCardStatus.visibility = View.VISIBLE
|
||||||
binding.root.alpha = 0.45f
|
binding.root.alpha = 0.45f
|
||||||
|
} else {
|
||||||
|
binding.tvCardStatus.visibility = View.GONE
|
||||||
|
binding.root.alpha = 1f
|
||||||
}
|
}
|
||||||
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,35 +153,15 @@ class AccountsAdapter(
|
|||||||
private const val TYPE_ACCOUNT = 1
|
private const val TYPE_ACCOUNT = 1
|
||||||
private const val TYPE_CARD = 2
|
private const val TYPE_CARD = 2
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
private fun copyToClipboard(context: Context, accountNumber: String) {
|
private fun copyToClipboard(context: Context, accountNumber: String) {
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
||||||
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun friendlyAccountType(raw: String): String {
|
|
||||||
val u = raw.trim().uppercase()
|
|
||||||
return when {
|
|
||||||
u == "SAVINGS ACCOUNT" ||
|
|
||||||
u == "SAVING ACCOUNT" -> "Savings"
|
|
||||||
u == "CURRENT ACCOUNT" ||
|
|
||||||
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
|
||||||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
|
||||||
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
|
||||||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
|
||||||
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
|
||||||
else -> raw.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Brand(val label: String, val color: String)
|
|
||||||
|
|
||||||
private fun cardBrand(productName: String): Brand = when {
|
|
||||||
productName.contains("AMEX", ignoreCase = true) ||
|
|
||||||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> Brand("AMEX", "#016FD0")
|
|
||||||
productName.contains("VISA", ignoreCase = true) -> Brand("VISA", "#1A1F71")
|
|
||||||
productName.contains("MASTERCARD", ignoreCase = true) -> Brand("MC", "#FF5F00")
|
|
||||||
else -> Brand("CARD", "#555555")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -26,10 +28,23 @@ class AccountsFragment : Fragment() {
|
|||||||
adapter = AccountsAdapter(emptyList()) { account ->
|
adapter = AccountsAdapter(emptyList()) { account ->
|
||||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||||
}
|
}
|
||||||
|
adapter.onTransferClick = { account ->
|
||||||
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||||
|
}
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ActivitiesAdapter(
|
||||||
|
private val onItemClick: (ReceiptStore.Entry) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class DateHeader(val label: String) : Item()
|
||||||
|
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val displayItems = mutableListOf<Item>()
|
||||||
|
|
||||||
|
fun setEntries(entries: List<ReceiptStore.Entry>) {
|
||||||
|
displayItems.clear()
|
||||||
|
var lastDateKey = ""
|
||||||
|
for (entry in entries) {
|
||||||
|
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
|
||||||
|
if (dateKey != lastDateKey) {
|
||||||
|
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
|
||||||
|
lastDateKey = dateKey
|
||||||
|
}
|
||||||
|
displayItems.add(Item.ReceiptItem(entry))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = displayItems.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) =
|
||||||
|
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE_HEADER)
|
||||||
|
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||||
|
else
|
||||||
|
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (holder) {
|
||||||
|
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||||
|
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ReceiptVH(private val b: ItemTransactionBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(entry: ReceiptStore.Entry) {
|
||||||
|
val d = entry.data
|
||||||
|
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
|
||||||
|
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
|
b.fvAvatar.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
|
||||||
|
}
|
||||||
|
b.tvInitial.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvInitial.text = initial
|
||||||
|
|
||||||
|
b.tvCounterparty.text = d.toLabel
|
||||||
|
b.tvCounterparty.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvDescription.text = buildString {
|
||||||
|
append(d.fromLabel)
|
||||||
|
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
|
||||||
|
}
|
||||||
|
b.tvDate.text = formatTime(entry.savedAt)
|
||||||
|
|
||||||
|
b.tvAmount.text = "- ${d.currency} ${d.amount}"
|
||||||
|
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
|
||||||
|
|
||||||
|
b.root.setOnClickListener { onItemClick(entry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDateHeader(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("HH:mm", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE_HEADER = 0
|
||||||
|
private const val TYPE_RECEIPT = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
|
||||||
|
class ActivitiesFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentActivitiesBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var adapter: ActivitiesAdapter
|
||||||
|
private val allEntries = mutableListOf<ReceiptStore.Entry>()
|
||||||
|
private var searchQuery = ""
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ActivitiesAdapter { entry ->
|
||||||
|
(activity as? HomeActivity)?.showWithBackStack(
|
||||||
|
TransferReceiptFragment.newInstance(entry.data, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext()
|
||||||
|
.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.etSearch.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
searchQuery = s?.toString()?.trim() ?: ""
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_activities)
|
||||||
|
// Reload in case a new receipt was added while we were away
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadEntries() {
|
||||||
|
allEntries.clear()
|
||||||
|
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterAndDisplay() {
|
||||||
|
val filtered = if (searchQuery.isBlank()) allEntries
|
||||||
|
else allEntries.filter { entry ->
|
||||||
|
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
adapter.setEntries(filtered)
|
||||||
|
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,18 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Filter
|
||||||
|
import android.widget.Filterable
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -43,7 +49,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
private data class DestinationOption(
|
private data class DestinationOption(
|
||||||
val label: String,
|
val label: String,
|
||||||
val isBml: Boolean,
|
val isBml: Boolean,
|
||||||
val mibProfile: MibProfile? = null
|
val mibProfile: MibProfile? = null,
|
||||||
|
val mibLoginId: String? = null,
|
||||||
|
val bmlLoginId: String? = null,
|
||||||
|
val subtitle: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
private var destinations: List<DestinationOption> = emptyList()
|
private var destinations: List<DestinationOption> = emptyList()
|
||||||
@@ -83,21 +92,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun buildDestinations(): List<DestinationOption> {
|
private fun buildDestinations(): List<DestinationOption> {
|
||||||
val list = mutableListOf<DestinationOption>()
|
val list = mutableListOf<DestinationOption>()
|
||||||
for (profile in app.mibProfiles) {
|
for ((loginId, profiles) in app.mibProfilesMap) {
|
||||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
|
for (profile in profiles) {
|
||||||
|
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (app.bmlSession != null) {
|
val store = CredentialStore(requireContext())
|
||||||
list.add(DestinationOption("BML · Personal", isBml = true))
|
for ((loginId, profiles) in app.bmlProfilesMap) {
|
||||||
|
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
for (profile in profiles) {
|
||||||
|
if (app.bmlSessions.containsKey(profile.profileId)) {
|
||||||
|
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupDestinationDropdown() {
|
private fun setupDestinationDropdown() {
|
||||||
val labels = destinations.map { it.label }
|
val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
|
||||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels)
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||||
|
getDropDownView(position, convertView, parent)
|
||||||
|
|
||||||
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, parent, false)
|
||||||
|
val opt = destinations[position]
|
||||||
|
view.findViewById<TextView>(android.R.id.text1).text = opt.label
|
||||||
|
val text2 = view.findViewById<TextView>(android.R.id.text2)
|
||||||
|
if (opt.subtitle.isNotBlank()) {
|
||||||
|
text2.text = opt.subtitle
|
||||||
|
text2.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
text2.visibility = View.GONE
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = destinations; count = destinations.size }
|
||||||
|
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||||
|
override fun convertResultToString(r: Any?) = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
binding.actvDestination.setAdapter(adapter)
|
binding.actvDestination.setAdapter(adapter)
|
||||||
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
||||||
selectedDest = destinations[position]
|
selectedDest = destinations[position]
|
||||||
|
binding.actvDestination.setText(destinations[position].label, false)
|
||||||
clearLookupResult()
|
clearLookupResult()
|
||||||
updateMibOnlyVisibility()
|
updateMibOnlyVisibility()
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
@@ -129,6 +169,22 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startLookupLoading() {
|
||||||
|
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||||
|
setStyle(CircularProgressDrawable.DEFAULT)
|
||||||
|
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
binding.tilAccount.endIconDrawable = spinner
|
||||||
|
binding.tilAccount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLookupLoading() {
|
||||||
|
binding.tilAccount.isEnabled = true
|
||||||
|
binding.tilAccount.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupAccountSearch() {
|
private fun setupAccountSearch() {
|
||||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||||
}
|
}
|
||||||
@@ -167,7 +223,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.tilAccount.isEnabled = false
|
startLookupLoading()
|
||||||
binding.tilDestination.isEnabled = false
|
binding.tilDestination.isEnabled = false
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
|
|
||||||
@@ -175,7 +231,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
||||||
}
|
}
|
||||||
binding.tilAccount.isEnabled = true
|
stopLookupLoading()
|
||||||
binding.tilDestination.isEnabled = true
|
binding.tilDestination.isEnabled = true
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
showLookupResult(result, input)
|
showLookupResult(result, input)
|
||||||
@@ -186,7 +242,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupForBml(input: String): BmlAccountValidation? {
|
private fun lookupForBml(input: String): BmlAccountValidation? {
|
||||||
val bmlSess = app.bmlSession ?: return null
|
val loginId = selectedDest?.bmlLoginId ?: return null
|
||||||
|
val bmlSess = app.bmlSessions[loginId] ?: return null
|
||||||
val bmlFlow = BmlLoginFlow()
|
val bmlFlow = BmlLoginFlow()
|
||||||
|
|
||||||
// 1) Try BML validate
|
// 1) Try BML validate
|
||||||
@@ -198,7 +255,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
if (mibVerified != null) return mibVerified
|
if (mibVerified != null) return mibVerified
|
||||||
|
|
||||||
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
||||||
val mibSess = app.mibSession ?: return null
|
val mibSess = app.anyMibSession() ?: return null
|
||||||
return try {
|
return try {
|
||||||
val info = MibTransferClient().lookup(mibSess, input)
|
val info = MibTransferClient().lookup(mibSess, input)
|
||||||
BmlAccountValidation(
|
BmlAccountValidation(
|
||||||
@@ -215,11 +272,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
|
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
|
||||||
val mibSess = app.mibSession ?: return null
|
val loginId = dest.mibLoginId ?: return null
|
||||||
|
val mibSess = app.mibSessions[loginId] ?: return null
|
||||||
val profile = dest.mibProfile ?: return null
|
val profile = dest.mibProfile ?: return null
|
||||||
|
|
||||||
val mibResult = try {
|
val mibResult = try {
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||||
val info = MibTransferClient().lookup(mibSess, input)
|
val info = MibTransferClient().lookup(mibSess, input)
|
||||||
BmlAccountValidation(
|
BmlAccountValidation(
|
||||||
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
||||||
@@ -236,7 +294,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
if (mibResult != null) return mibResult
|
if (mibResult != null) return mibResult
|
||||||
|
|
||||||
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
||||||
val bmlSess = app.bmlSession ?: return null
|
val bmlSess = app.anyBmlSession() ?: return null
|
||||||
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,6 +399,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
binding.tilAlias.error = null
|
binding.tilAlias.error = null
|
||||||
|
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
|
binding.btnSave.text = "Saving..."
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) {
|
||||||
@@ -352,13 +411,15 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
binding.btnSave.isEnabled = true
|
binding.btnSave.isEnabled = true
|
||||||
|
binding.btnSave.text = "Save"
|
||||||
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveToBml(alias: String): Boolean {
|
private fun saveToBml(alias: String): Boolean {
|
||||||
val bmlSess = app.bmlSession ?: return false
|
val loginId = selectedDest?.bmlLoginId ?: return false
|
||||||
|
val bmlSess = app.bmlSessions[loginId] ?: return false
|
||||||
val lookup = bmlLookup ?: return false
|
val lookup = bmlLookup ?: return false
|
||||||
val bmlFlow = BmlLoginFlow()
|
val bmlFlow = BmlLoginFlow()
|
||||||
val account = lookup.account
|
val account = lookup.account
|
||||||
@@ -375,8 +436,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun saveToMib(alias: String): Boolean {
|
private fun saveToMib(alias: String): Boolean {
|
||||||
val mibSess = app.mibSession ?: return false
|
|
||||||
val dest = selectedDest ?: return false
|
val dest = selectedDest ?: return false
|
||||||
|
val loginId = dest.mibLoginId ?: return false
|
||||||
|
val mibSess = app.mibSessions[loginId] ?: return false
|
||||||
val profile = dest.mibProfile ?: return false
|
val profile = dest.mibProfile ?: return false
|
||||||
val account = mibLookupAccount ?: return false
|
val account = mibLookupAccount ?: return false
|
||||||
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
|
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
|
||||||
@@ -388,7 +450,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val name = bmlLookup?.name ?: ""
|
val name = bmlLookup?.name ?: ""
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||||
MibContactsClient().createContact(
|
MibContactsClient().createContact(
|
||||||
session = mibSess,
|
session = mibSess,
|
||||||
benefType = benefType,
|
benefType = benefType,
|
||||||
@@ -425,16 +487,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (dest.isBml) {
|
if (dest.isBml) {
|
||||||
val bmlSess = app.bmlSession ?: return@launch
|
val loginId = dest.bmlLoginId ?: return@launch
|
||||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess)
|
val bmlSess = app.bmlSessions[loginId] ?: return@launch
|
||||||
|
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
|
||||||
val existing = viewModel.contacts.value ?: emptyList()
|
val existing = viewModel.contacts.value ?: emptyList()
|
||||||
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
||||||
viewModel.contacts.postValue(merged)
|
viewModel.contacts.postValue(merged)
|
||||||
ContactsCache.saveBml(requireContext(), fresh)
|
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
|
||||||
} else {
|
} else {
|
||||||
val profile = dest.mibProfile ?: return@launch
|
val profile = dest.mibProfile ?: return@launch
|
||||||
val mibSess = app.mibSession ?: return@launch
|
val mibLoginId = dest.mibLoginId ?: return@launch
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
|
||||||
|
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
|
||||||
val fresh = MibContactsClient().fetchContacts(mibSess)
|
val fresh = MibContactsClient().fetchContacts(mibSess)
|
||||||
.map { it.copy(profileId = profile.profileId) }
|
.map { it.copy(profileId = profile.profileId) }
|
||||||
val existing = viewModel.contacts.value ?: emptyList()
|
val existing = viewModel.contacts.value ?: emptyList()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sh.sar.basedbank.databinding.ItemPickerRowBinding
|
|||||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||||
|
|
||||||
class ContactPickerAdapter(
|
class ContactPickerAdapter(
|
||||||
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onItemClick: (accountNumber: String, label: String) -> Unit,
|
private val onItemClick: (accountNumber: String, label: String) -> Unit,
|
||||||
private val onSameAsFrom: () -> Unit,
|
private val onSameAsFrom: () -> Unit,
|
||||||
private val onImageNeeded: ((hash: String) -> Unit)? = null,
|
private val onImageNeeded: ((hash: String) -> Unit)? = null,
|
||||||
@@ -35,7 +36,6 @@ class ContactPickerAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var items: List<PickerItem> = emptyList()
|
private var items: List<PickerItem> = emptyList()
|
||||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
|
||||||
|
|
||||||
fun updateImage(hash: String, bitmap: Bitmap) {
|
fun updateImage(hash: String, bitmap: Bitmap) {
|
||||||
imageCache[hash] = bitmap
|
imageCache[hash] = bitmap
|
||||||
|
|||||||
@@ -33,9 +33,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private val pendingHashes = mutableSetOf<String>()
|
private val pendingHashes = mutableSetOf<String>()
|
||||||
|
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
||||||
private val profileImageHashes = mutableSetOf<String>()
|
private val profileImageHashes = mutableSetOf<String>()
|
||||||
private val app get() = requireActivity().application as BasedBankApp
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
private val session get() = app.mibSession
|
private val session get() = app.anyMibSession()
|
||||||
|
|
||||||
private var fromAccountNumber: String = ""
|
private var fromAccountNumber: String = ""
|
||||||
private var mediator: TabLayoutMediator? = null
|
private var mediator: TabLayoutMediator? = null
|
||||||
@@ -48,6 +49,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
|
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
|
||||||
ContactPickerAdapter(
|
ContactPickerAdapter(
|
||||||
|
imageCache = sharedImageCache,
|
||||||
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
|
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
|
||||||
onSameAsFrom = {},
|
onSameAsFrom = {},
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
@@ -120,6 +122,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
attachMediator(initialPages)
|
attachMediator(initialPages)
|
||||||
|
|
||||||
binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() }
|
binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() }
|
||||||
|
binding.etSheetSearch.setOnFocusChangeListener { _, hasFocus ->
|
||||||
|
if (hasFocus) {
|
||||||
|
val allIndex = pagerAdapter.pages.indexOfFirst { it.tag == null }
|
||||||
|
if (allIndex >= 0) binding.viewPager.setCurrentItem(allIndex, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||||
val pages = buildList {
|
val pages = buildList {
|
||||||
@@ -137,6 +145,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
|
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachMediator(pages: List<TabDef>) {
|
private fun attachMediator(pages: List<TabDef>) {
|
||||||
@@ -153,17 +163,19 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||||
when {
|
when {
|
||||||
account?.profileType == "BML_PREPAID" -> {
|
account != null -> {
|
||||||
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
|
|
||||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||||
bundle.putString(KEY_COLOR, "#FE860E")
|
bundle.putString(KEY_COLOR, "#FE860E")
|
||||||
}
|
}
|
||||||
contact != null && !contact.transferCyDesc.equals("MVR", ignoreCase = true) -> {
|
contact != null -> {
|
||||||
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
|
|
||||||
bundle.putString(KEY_SUBTITLE, "${contact.benefBankName} · ${contact.benefAccount}")
|
bundle.putString(KEY_SUBTITLE, "${contact.benefBankName} · ${contact.benefAccount}")
|
||||||
bundle.putString(KEY_COLOR, contact.bankColor)
|
bundle.putString(KEY_COLOR, contact.bankColor)
|
||||||
contact.customerImgHash?.let { bundle.putString(KEY_IMAGE_HASH, it) }
|
contact.customerImgHash?.let { bundle.putString(KEY_IMAGE_HASH, it) }
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
|
bundle.putString(KEY_SUBTITLE, accountNumber)
|
||||||
|
bundle.putString(KEY_COLOR, "#607D8B")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setFragmentResult(REQUEST_KEY, bundle)
|
setFragmentResult(REQUEST_KEY, bundle)
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -197,11 +209,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID"
|
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT"
|
||||||
|
|
||||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" }
|
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" }
|
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||||
|
|
||||||
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
|
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
|
||||||
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
|
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
|
||||||
@@ -279,7 +291,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = if (hash in profileImageHashes) {
|
val base64 = if (hash in profileImageHashes) {
|
||||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||||
} else {
|
} else {
|
||||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
@@ -306,7 +318,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
const val REQUEST_KEY = "contact_picker"
|
const val REQUEST_KEY = "contact_picker"
|
||||||
const val KEY_ACCOUNT_NUMBER = "accountNumber"
|
const val KEY_ACCOUNT_NUMBER = "accountNumber"
|
||||||
const val KEY_LABEL = "label"
|
const val KEY_LABEL = "label"
|
||||||
const val KEY_SKIP_LOOKUP = "skipLookup"
|
|
||||||
const val KEY_SUBTITLE = "subtitle"
|
const val KEY_SUBTITLE = "subtitle"
|
||||||
const val KEY_COLOR = "color"
|
const val KEY_COLOR = "color"
|
||||||
const val KEY_IMAGE_HASH = "imageHash"
|
const val KEY_IMAGE_HASH = "imageHash"
|
||||||
|
|||||||
@@ -8,55 +8,51 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
class ContactsAdapter(
|
class ContactsAdapter(
|
||||||
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onImageNeeded: (hash: String) -> Unit,
|
private val onImageNeeded: (hash: String) -> Unit,
|
||||||
private val onDeleteClick: (MibBeneficiary) -> Unit,
|
private val onDeleteClick: (ContactDisplay) -> Unit,
|
||||||
private val onTransferClick: (MibBeneficiary) -> Unit
|
private val onTransferClick: (ContactDisplay) -> Unit
|
||||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
private var allContacts: List<ContactDisplay> = emptyList()
|
||||||
private var displayed: List<MibBeneficiary> = emptyList()
|
private var displayed: List<ContactDisplay> = emptyList()
|
||||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
|
||||||
private val expandedPositions = mutableSetOf<Int>()
|
|
||||||
|
|
||||||
private var activeCategoryId: String? = null
|
private var activeCategoryId: String? = null
|
||||||
private var searchQuery: String = ""
|
private var searchQuery: String = ""
|
||||||
|
|
||||||
fun updateContacts(contacts: List<MibBeneficiary>) {
|
fun updateContacts(contacts: List<ContactDisplay>) {
|
||||||
allContacts = contacts
|
allContacts = contacts
|
||||||
expandedPositions.clear()
|
|
||||||
applyFilter()
|
applyFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateImage(hash: String, bitmap: Bitmap) {
|
fun updateImage(hash: String, bitmap: Bitmap) {
|
||||||
imageCache[hash] = bitmap
|
imageCache[hash] = bitmap
|
||||||
displayed.forEachIndexed { index, contact ->
|
displayed.forEachIndexed { index, contact ->
|
||||||
if (contact.customerImgHash == hash) notifyItemChanged(index)
|
if (contact.imageHash == hash) notifyItemChanged(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setFilter(categoryId: String?, query: String) {
|
fun setFilter(categoryId: String?, query: String) {
|
||||||
activeCategoryId = categoryId
|
activeCategoryId = categoryId
|
||||||
searchQuery = query
|
searchQuery = query
|
||||||
expandedPositions.clear()
|
|
||||||
applyFilter()
|
applyFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyFilter() {
|
private fun applyFilter() {
|
||||||
displayed = allContacts.filter { contact ->
|
displayed = allContacts.filter { contact ->
|
||||||
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
|
val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
|
||||||
val matchesSearch = searchQuery.isBlank() ||
|
val matchesSearch = searchQuery.isBlank() ||
|
||||||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
|
contact.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
|
contact.realName.contains(searchQuery, ignoreCase = true) ||
|
||||||
contact.benefAccount.contains(searchQuery)
|
contact.accountNumber.contains(searchQuery)
|
||||||
matchesCategory && matchesSearch
|
matchesCategory && matchesSearch
|
||||||
}
|
}
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
@@ -64,42 +60,38 @@ class ContactsAdapter(
|
|||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
val binding = ItemContactBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
val binding = ItemContactBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
return ViewHolder(binding)
|
val holder = ViewHolder(binding)
|
||||||
|
|
||||||
|
binding.btnTransferContact.setOnClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
||||||
|
}
|
||||||
|
binding.btnEditContact.setOnClickListener {
|
||||||
|
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
binding.btnDeleteContact.setOnClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
|
||||||
|
}
|
||||||
|
binding.root.setOnLongClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
|
||||||
|
val account = displayed[pos].accountNumber
|
||||||
|
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||||
|
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
return holder
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
val contact = displayed[position]
|
val contact = displayed[position]
|
||||||
val cachedImage = contact.customerImgHash?.let { hash ->
|
val cachedImage = contact.imageHash?.let { hash ->
|
||||||
imageCache[hash] ?: run { onImageNeeded(hash); null }
|
imageCache[hash] ?: run { onImageNeeded(hash); null }
|
||||||
}
|
}
|
||||||
holder.bind(contact, cachedImage, position in expandedPositions)
|
holder.bind(contact, cachedImage)
|
||||||
|
|
||||||
holder.binding.root.setOnClickListener {
|
|
||||||
val pos = holder.bindingAdapterPosition
|
|
||||||
if (pos == RecyclerView.NO_POSITION) return@setOnClickListener
|
|
||||||
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
|
|
||||||
notifyItemChanged(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.binding.root.setOnLongClickListener {
|
|
||||||
val ctx = it.context
|
|
||||||
val clipboard = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("account", contact.benefAccount))
|
|
||||||
Toast.makeText(ctx, contact.benefAccount, Toast.LENGTH_SHORT).show()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.binding.btnTransferContact.setOnClickListener {
|
|
||||||
onTransferClick(contact)
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.binding.btnEditContact.setOnClickListener {
|
|
||||||
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
holder.binding.btnDeleteContact.setOnClickListener {
|
|
||||||
onDeleteClick(contact)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = displayed.size
|
override fun getItemCount() = displayed.size
|
||||||
@@ -107,24 +99,26 @@ class ContactsAdapter(
|
|||||||
inner class ViewHolder(val binding: ItemContactBinding) :
|
inner class ViewHolder(val binding: ItemContactBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(contact: MibBeneficiary, photo: Bitmap?, expanded: Boolean) {
|
fun bind(contact: ContactDisplay, photo: Bitmap?) {
|
||||||
binding.tvContactName.text = contact.benefNickName
|
binding.tvContactName.text = contact.name
|
||||||
binding.tvContactBank.text = contact.benefBankName
|
binding.tvContactAccount.text = contact.accountNumber
|
||||||
binding.tvContactAccount.text = "${contact.benefAccount} · ${contact.transferCyDesc}"
|
binding.tvRealName.text = contact.detail ?: ""
|
||||||
|
binding.tvRealName.visibility =
|
||||||
|
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
binding.btnTransferContact.visibility =
|
||||||
|
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
binding.btnEditContact.visibility =
|
||||||
|
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
binding.btnDeleteContact.visibility =
|
||||||
|
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
binding.ivContactPhoto.setImageBitmap(photo)
|
binding.ivContactPhoto.setImageBitmap(photo)
|
||||||
} else {
|
} else {
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
binding.ivContactPhoto.setImageBitmap(
|
||||||
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
|
makeInitialsBitmap(contact.name, contact.bankColor)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.tvRealName.text = contact.benefName
|
|
||||||
|
|
||||||
val vis = if (expanded) View.VISIBLE else View.GONE
|
|
||||||
binding.dividerExpand.visibility = vis
|
|
||||||
binding.expandedSection.visibility = vis
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
@@ -21,13 +23,15 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.ContactManager
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
class ContactsFragment : Fragment() {
|
class ContactsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -36,10 +40,11 @@ class ContactsFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private val pendingHashes = mutableSetOf<String>()
|
private val pendingHashes = mutableSetOf<String>()
|
||||||
|
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
||||||
private val app get() = requireActivity().application as BasedBankApp
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
private val session get() = app.mibSession
|
private val session get() = app.anyMibSession()
|
||||||
|
|
||||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
private var allContacts: List<ContactDisplay> = emptyList()
|
||||||
private var currentSearch: String = ""
|
private var currentSearch: String = ""
|
||||||
private var mediator: TabLayoutMediator? = null
|
private var mediator: TabLayoutMediator? = null
|
||||||
private lateinit var pagerAdapter: ContactsPagerAdapter
|
private lateinit var pagerAdapter: ContactsPagerAdapter
|
||||||
@@ -52,8 +57,9 @@ class ContactsFragment : Fragment() {
|
|||||||
private val density get() = resources.displayMetrics.density
|
private val density get() = resources.displayMetrics.density
|
||||||
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
|
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
|
||||||
ContactsAdapter(
|
ContactsAdapter(
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
imageCache = sharedImageCache,
|
||||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
|
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||||
onTransferClick = { contact -> openTransfer(contact) }
|
onTransferClick = { contact -> openTransfer(contact) }
|
||||||
).also { a ->
|
).also { a ->
|
||||||
a.setFilter(page.categoryId, currentSearch)
|
a.setFilter(page.categoryId, currentSearch)
|
||||||
@@ -61,7 +67,7 @@ class ContactsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateContacts(contacts: List<MibBeneficiary>) =
|
fun updateContacts(contacts: List<ContactDisplay>) =
|
||||||
contactAdapters.forEach { it.updateContacts(contacts) }
|
contactAdapters.forEach { it.updateContacts(contacts) }
|
||||||
|
|
||||||
fun updateSearch(query: String) =
|
fun updateSearch(query: String) =
|
||||||
@@ -84,7 +90,7 @@ class ContactsFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
clipToPadding = false
|
clipToPadding = false
|
||||||
val p4 = (4 * density).toInt()
|
val p4 = (4 * density).toInt()
|
||||||
val p80 = (80 * density).toInt()
|
val p80 = (65 * density).toInt()
|
||||||
setPadding(0, p4, 0, p80)
|
setPadding(0, p4, 0, p80)
|
||||||
adapter = contactAdapters[viewType]
|
adapter = contactAdapters[viewType]
|
||||||
}
|
}
|
||||||
@@ -111,17 +117,30 @@ class ContactsFragment : Fragment() {
|
|||||||
pagerAdapter.updateSearch(currentSearch)
|
pagerAdapter.updateSearch(currentSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fabMarginBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.fabAddContact) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
val lp = v.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
|
||||||
|
lp.bottomMargin = fabMarginBase + extraBottom
|
||||||
|
v.layoutParams = lp
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.fabAddContact.setOnClickListener {
|
binding.fabAddContact.setOnClickListener {
|
||||||
AddContactSheetFragment().show(childFragmentManager, "add_contact")
|
AddContactSheetFragment().show(childFragmentManager, "add_contact")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
|
||||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||||
rebuildPager(cats)
|
rebuildPager(cats)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
allContacts = contacts
|
allContacts = ContactListParser.fromList(contacts)
|
||||||
pagerAdapter.updateContacts(contacts)
|
pagerAdapter.updateContacts(allContacts)
|
||||||
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
||||||
binding.loadingView.visibility = View.GONE
|
binding.loadingView.visibility = View.GONE
|
||||||
}
|
}
|
||||||
@@ -146,31 +165,29 @@ class ContactsFragment : Fragment() {
|
|||||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openTransfer(contact: MibBeneficiary) {
|
private fun openTransfer(contact: ContactDisplay) {
|
||||||
val fragment = TransferFragment.newInstance(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.benefAccount,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.benefNickName,
|
displayName = contact.name,
|
||||||
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
subtitle = contact.transferSubtitle,
|
||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
imageHash = contact.customerImgHash
|
imageHash = contact.imageHash
|
||||||
)
|
)
|
||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: MibBeneficiary) {
|
private fun confirmDelete(contact: ContactDisplay) {
|
||||||
AlertDialog.Builder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(R.string.contact_delete_title)
|
.setTitle(R.string.contact_delete_title)
|
||||||
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName))
|
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
||||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteContact(contact: MibBeneficiary) {
|
private fun deleteContact(contact: ContactDisplay) {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
|
||||||
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
|
|
||||||
}
|
|
||||||
if (success) {
|
if (success) {
|
||||||
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
|
||||||
removeFromViewModel(contact)
|
removeFromViewModel(contact)
|
||||||
@@ -180,28 +197,15 @@ class ContactsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteBml(contact: MibBeneficiary): Boolean {
|
private fun removeFromViewModel(contact: ContactDisplay) {
|
||||||
val sess = app.bmlSession ?: return false
|
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
|
||||||
val contactId = contact.benefNo.removePrefix("bml_")
|
|
||||||
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteMib(contact: MibBeneficiary): Boolean {
|
|
||||||
val sess = session ?: return false
|
|
||||||
return try {
|
|
||||||
if (contact.profileId.isNotBlank()) {
|
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
|
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
|
|
||||||
}
|
|
||||||
MibContactsClient().deleteContact(sess, contact.benefNo)
|
|
||||||
} catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeFromViewModel(contact: MibBeneficiary) {
|
|
||||||
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
|
|
||||||
viewModel.contacts.value = updated
|
viewModel.contacts.value = updated
|
||||||
if (contact.benefCategoryId == "BML") {
|
if (contact.network == TransferNetwork.BML) {
|
||||||
ContactsCache.saveBml(requireContext(), updated.filter { it.benefCategoryId == "BML" })
|
updated.filter { it.benefCategoryId == "BML" }
|
||||||
|
.groupBy { it.profileId }
|
||||||
|
.forEach { (loginId, contacts) ->
|
||||||
|
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, contacts)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ContactsCache.save(
|
ContactsCache.save(
|
||||||
requireContext(),
|
requireContext(),
|
||||||
@@ -213,7 +217,6 @@ class ContactsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun fetchImage(hash: String) {
|
private fun fetchImage(hash: String) {
|
||||||
if (!pendingHashes.add(hash)) return
|
if (!pendingHashes.add(hash)) return
|
||||||
// Check disk cache first — if hash matches we already have the image
|
|
||||||
val cached = ContactImageCache.load(requireContext(), hash)
|
val cached = ContactImageCache.load(requireContext(), hash)
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
view?.post { pagerAdapter.updateImage(hash, cached) }
|
view?.post { pagerAdapter.updateImage(hash, cached) }
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -29,21 +32,49 @@ class DashboardFragment : Fragment() {
|
|||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
||||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||||
|
updatePendingFinances(viewModel.financing.value ?: emptyList())
|
||||||
|
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
binding.btnTransfer.setOnClickListener {
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment())
|
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||||
}
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
binding.btnPayMvQr.setOnClickListener {
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.nav_dashboard)
|
requireActivity().title = getString(R.string.nav_dashboard)
|
||||||
|
refreshQuickActions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshQuickActions() {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val ids = NavCustomization.getQuickActions(prefs)
|
||||||
|
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
||||||
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
||||||
|
if (def != null) {
|
||||||
|
btn.setText(def.titleRes)
|
||||||
|
btn.icon = ContextCompat.getDrawable(requireContext(), def.iconRes)
|
||||||
|
}
|
||||||
|
btn.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(ids[i]) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateBalances(accounts: List<MibAccount>) {
|
private fun updateBalances(accounts: List<MibAccount>) {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
if (hide) {
|
||||||
|
binding.tvMvrBalance.text = "MVR ••••••"
|
||||||
|
binding.tvUsdBalance.text = "USD ••••••"
|
||||||
|
return
|
||||||
|
}
|
||||||
val mvrTotal = accounts
|
val mvrTotal = accounts
|
||||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
@@ -56,31 +87,40 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
binding.containerForeignLimits.removeAllViews()
|
binding.containerForeignLimits.removeAllViews()
|
||||||
for (entry in entries) {
|
for (entry in entries) {
|
||||||
for (limit in entry.limits) {
|
for (limit in entry.limits) {
|
||||||
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
||||||
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
||||||
card.tvLimitType.text = limit.type
|
card.tvLimitType.text = limit.type
|
||||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
if (hide) {
|
||||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
card.tvLimitGeneral.text = "USD ••••••"
|
||||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
card.tvLimitMedical.text = "USD ••••••"
|
||||||
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
||||||
else
|
card.tvLimitEcom.text = "USD ••••••"
|
||||||
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
|
||||||
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
} else {
|
||||||
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||||
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
||||||
else
|
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
||||||
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
||||||
|
else
|
||||||
|
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||||
|
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||||
|
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
||||||
|
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
||||||
|
else
|
||||||
|
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||||
|
}
|
||||||
binding.containerForeignLimits.addView(card.root)
|
binding.containerForeignLimits.addView(card.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
||||||
val total = deals.sumOf { it.outstandingAmount }
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
|
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import java.util.Locale
|
|||||||
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||||
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
private val expandedPositions = mutableSetOf<Int>()
|
private val expandedPositions = mutableSetOf<Int>()
|
||||||
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||||
minimumFractionDigits = 2
|
minimumFractionDigits = 2
|
||||||
@@ -52,19 +60,20 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
||||||
val ctx = binding.root.context
|
val ctx = binding.root.context
|
||||||
val currency = deal.currency
|
val currency = deal.currency
|
||||||
|
val hide = hideAmounts
|
||||||
|
|
||||||
binding.tvProductName.text = deal.productDesc
|
binding.tvProductName.text = deal.productDesc
|
||||||
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
||||||
binding.tvStatus.text = deal.statusDesc
|
binding.tvStatus.text = deal.statusDesc
|
||||||
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
|
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||||
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
|
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||||
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||||
|
|
||||||
// Progress bar
|
// Progress bar
|
||||||
val progress = if (deal.dealAmount > 0)
|
val progress = if (deal.dealAmount > 0)
|
||||||
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
||||||
else 0
|
else 0
|
||||||
binding.progressBar.progress = progress
|
binding.progressBar.progress = if (hide) 0 else progress
|
||||||
|
|
||||||
// Completion estimate
|
// Completion estimate
|
||||||
binding.tvCompletion.text = completionText(deal, ctx)
|
binding.tvCompletion.text = completionText(deal, ctx)
|
||||||
@@ -76,14 +85,14 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
binding.tvDealDate.text = formatDate(deal.dealDate)
|
binding.tvDealDate.text = formatDate(deal.dealDate)
|
||||||
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
|
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||||
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
||||||
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
||||||
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||||
|
|
||||||
if (deal.overdueAmount > 0) {
|
if (deal.overdueAmount > 0) {
|
||||||
binding.rowOverdue.visibility = View.VISIBLE
|
binding.rowOverdue.visibility = View.VISIBLE
|
||||||
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
|
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||||
} else {
|
} else {
|
||||||
binding.rowOverdue.visibility = View.GONE
|
binding.rowOverdue.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -27,12 +29,22 @@ class FinancingFragment : Fragment() {
|
|||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
||||||
adapter.updateDeals(deals)
|
adapter.updateDeals(deals)
|
||||||
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
|
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
|
||||||
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
||||||
binding.loadingView.visibility = View.GONE
|
binding.loadingView.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.CountDownTimer
|
import android.os.CountDownTimer
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
@@ -8,12 +9,18 @@ import android.os.Looper
|
|||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import com.google.android.material.navigation.NavigationBarView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import sh.sar.basedbank.ui.home.NavCustomization
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
@@ -29,8 +36,12 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.AuthExpiredException
|
import sh.sar.basedbank.api.bml.AuthExpiredException
|
||||||
|
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.databinding.ActivityHomeBinding
|
import sh.sar.basedbank.databinding.ActivityHomeBinding
|
||||||
@@ -52,123 +63,165 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private lateinit var binding: ActivityHomeBinding
|
private lateinit var binding: ActivityHomeBinding
|
||||||
private val viewModel: HomeViewModel by viewModels()
|
private val viewModel: HomeViewModel by viewModels()
|
||||||
|
private lateinit var toggle: ActionBarDrawerToggle
|
||||||
|
private var suppressBottomNavCallback = false
|
||||||
|
|
||||||
private val autolockHandler = Handler(Looper.getMainLooper())
|
private val autolockHandler = Handler(Looper.getMainLooper())
|
||||||
private var warningDialog: AlertDialog? = null
|
private var warningDialog: AlertDialog? = null
|
||||||
private var countdownTimer: CountDownTimer? = null
|
private var countdownTimer: CountDownTimer? = null
|
||||||
|
private var pauseTime = 0L
|
||||||
|
|
||||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||||
|
|
||||||
|
private var isLocked = false
|
||||||
|
|
||||||
private val autolockRunnable = Runnable {
|
private val autolockRunnable = Runnable {
|
||||||
countdownTimer?.cancel(); countdownTimer = null
|
countdownTimer?.cancel(); countdownTimer = null
|
||||||
warningDialog?.dismiss(); warningDialog = null
|
warningDialog?.dismiss(); warningDialog = null
|
||||||
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
|
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
.getString("security_method", null) != null
|
.getString("security_method", null) != null
|
||||||
if (securitySet) {
|
if (securitySet) lock()
|
||||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
}
|
||||||
finish()
|
|
||||||
}
|
private fun lock() {
|
||||||
|
isLocked = true
|
||||||
|
startActivity(
|
||||||
|
Intent(this, sh.sar.basedbank.LockActivity::class.java)
|
||||||
|
.putExtra(sh.sar.basedbank.LockActivity.EXTRA_RESUME, true)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityHomeBinding.inflate(layoutInflater)
|
binding = ActivityHomeBinding.inflate(layoutInflater)
|
||||||
|
if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) {
|
||||||
|
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
setSupportActionBar(binding.toolbar)
|
setSupportActionBar(binding.toolbar)
|
||||||
|
|
||||||
val toggle = ActionBarDrawerToggle(
|
toggle = ActionBarDrawerToggle(
|
||||||
this, binding.drawerLayout, binding.toolbar,
|
this, binding.drawerLayout, binding.toolbar,
|
||||||
R.string.nav_open_drawer, R.string.nav_close_drawer
|
R.string.nav_open_drawer, R.string.nav_close_drawer
|
||||||
)
|
)
|
||||||
binding.drawerLayout.addDrawerListener(toggle)
|
binding.drawerLayout.addDrawerListener(toggle)
|
||||||
toggle.syncState()
|
toggle.syncState()
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.navigationView) { v, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.updatePadding(top = bars.top, bottom = bars.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||||
|
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||||
|
val frag = when (item.itemId) {
|
||||||
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
|
R.id.nav_transfer -> TransferFragment()
|
||||||
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
|
R.id.nav_more -> MoreFragment()
|
||||||
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
|
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||||
|
R.id.nav_finances -> FinancingFragment()
|
||||||
|
R.id.nav_otp -> OtpFragment()
|
||||||
|
R.id.nav_settings -> SettingsFragment()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (frag != null) show(frag)
|
||||||
|
else Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
applyNavMode()
|
||||||
|
|
||||||
binding.navigationView.setNavigationItemSelectedListener { item ->
|
binding.navigationView.setNavigationItemSelectedListener { item ->
|
||||||
binding.drawerLayout.closeDrawers()
|
binding.drawerLayout.closeDrawers()
|
||||||
when (item.itemId) {
|
navigateTo(item.itemId)
|
||||||
R.id.nav_dashboard -> show(DashboardFragment())
|
|
||||||
R.id.nav_accounts -> show(AccountsFragment())
|
|
||||||
R.id.nav_contacts -> show(ContactsFragment())
|
|
||||||
R.id.nav_transfer_history -> show(TransferHistoryFragment())
|
|
||||||
R.id.nav_finances -> show(FinancingFragment())
|
|
||||||
R.id.nav_otp -> show(OtpFragment())
|
|
||||||
R.id.nav_settings -> show(SettingsFragment())
|
|
||||||
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty()) {
|
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
||||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||||
val mibAccounts = app.accounts.filter { it.profileType != "BML" }
|
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||||
val merged = mibAccounts + app.bmlAccounts
|
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
viewModel.accounts.value = merged
|
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
||||||
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
|
if (app.bmlAccounts.isNotEmpty()) {
|
||||||
if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts)
|
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
|
||||||
|
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
|
||||||
|
}
|
||||||
|
if (app.fahipayAccounts.isNotEmpty()) {
|
||||||
|
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
||||||
|
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||||
|
}
|
||||||
|
|
||||||
val cachedFinancing = FinancingCache.load(this)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
val cachedContacts = mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this))
|
|
||||||
if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts
|
|
||||||
val cachedCats = ContactsCache.loadCategories(this)
|
|
||||||
if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats
|
|
||||||
val cachedLimits = ForeignLimitsCache.load(this)
|
val cachedLimits = ForeignLimitsCache.load(this)
|
||||||
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
||||||
|
|
||||||
refreshFinancing(app.mibSession, app.mibProfiles)
|
for ((loginId, session) in app.mibSessions) {
|
||||||
refreshContacts(app.mibSession, app.mibProfiles)
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
if (app.bmlSession != null) {
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
refreshBmlContacts(app)
|
|
||||||
refreshBmlLimits(app.bmlSession!!)
|
|
||||||
}
|
}
|
||||||
|
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||||
} else {
|
} else {
|
||||||
// Came from lock screen — show caches immediately, refresh everything in background
|
// Came from lock screen — show caches immediately, refresh everything in background
|
||||||
|
val store = CredentialStore(this)
|
||||||
val cachedMib = AccountCache.load(this)
|
val cachedMib = AccountCache.load(this)
|
||||||
val cachedBml = AccountCache.loadBml(this)
|
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||||
val merged = cachedMib + cachedBml
|
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||||
|
val merged = cachedMib + cachedBml + cachedFahipay
|
||||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||||
val cachedFinancing = FinancingCache.load(this)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
val cachedContacts = mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this))
|
|
||||||
if (cachedContacts.isNotEmpty()) viewModel.contacts.value = cachedContacts
|
|
||||||
val cachedCats = ContactsCache.loadCategories(this)
|
|
||||||
if (cachedCats.isNotEmpty()) viewModel.contactCategories.value = cachedCats
|
|
||||||
val cachedLimits = ForeignLimitsCache.load(this)
|
val cachedLimits = ForeignLimitsCache.load(this)
|
||||||
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
|
||||||
|
|
||||||
val store = CredentialStore(this)
|
autoRefresh(store)
|
||||||
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hideAmounts is always false on launch; eye feature just needs to be enabled
|
||||||
|
viewModel.hideAmounts.value = false
|
||||||
|
|
||||||
// Show dashboard on first create
|
// Show dashboard on first create
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
show(DashboardFragment())
|
show(DashboardFragment())
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep MIB session alive every 25 seconds while the app is in the foreground
|
// Keep all MIB sessions alive every 25 seconds while the app is in the foreground
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(25_000)
|
delay(25_000)
|
||||||
val session = (application as BasedBankApp).mibSession ?: continue
|
val sessions = (application as BasedBankApp).mibSessions.values.toList()
|
||||||
withContext(Dispatchers.IO) {
|
for (session in sessions) {
|
||||||
try {
|
withContext(Dispatchers.IO) {
|
||||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
try {
|
||||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||||
val request = Request.Builder()
|
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
|
val request = Request.Builder()
|
||||||
.post(ByteArray(0).toRequestBody())
|
.url("https://faisamobilex-wv.mib.com.mv/aProfile/keepAlive")
|
||||||
.header("Cookie", cookieHeader)
|
.post(ByteArray(0).toRequestBody())
|
||||||
.header("User-Agent", "okhttp/4.11.0")
|
.header("Cookie", cookieHeader)
|
||||||
.header("Accept", "application/json, text/plain, */*")
|
.header("User-Agent", "okhttp/4.11.0")
|
||||||
.header("Accept-Encoding", "gzip")
|
.header("Accept", "application/json, text/plain, */*")
|
||||||
.header("Connection", "Keep-Alive")
|
.header("Accept-Encoding", "gzip")
|
||||||
.build()
|
.header("Connection", "Keep-Alive")
|
||||||
OkHttpClient().newCall(request).execute().close()
|
.build()
|
||||||
} catch (_: Exception) {}
|
OkHttpClient().newCall(request).execute().close()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +234,93 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun applyNavMode() {
|
||||||
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
|
if (isBottom) {
|
||||||
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
|
binding.bottomNavigation.visibility = View.VISIBLE
|
||||||
|
rebuildBottomNav(prefs)
|
||||||
|
applyNavLabelVisibility()
|
||||||
|
} else {
|
||||||
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||||
|
toggle.isDrawerIndicatorEnabled = true
|
||||||
|
toggle.syncState()
|
||||||
|
binding.bottomNavigation.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
|
||||||
|
val slots = NavCustomization.getSlots(prefs)
|
||||||
|
val menu = binding.bottomNavigation.menu
|
||||||
|
menu.clear()
|
||||||
|
menu.add(Menu.NONE, R.id.nav_dashboard, 0, R.string.nav_dashboard)
|
||||||
|
.setIcon(R.drawable.ic_nav_dashboard)
|
||||||
|
slots.forEachIndexed { i, id ->
|
||||||
|
val item = NavCustomization.ALL_SWAPPABLE.find { it.id == id } ?: return@forEachIndexed
|
||||||
|
menu.add(Menu.NONE, item.id, i + 1, item.titleRes).setIcon(item.iconRes)
|
||||||
|
}
|
||||||
|
menu.add(Menu.NONE, R.id.nav_more, 4, R.string.nav_more)
|
||||||
|
.setIcon(R.drawable.ic_nav_more)
|
||||||
|
// Restore selection to current destination after menu rebuild
|
||||||
|
val currentId = binding.navigationView.checkedItem?.itemId
|
||||||
|
if (currentId != null) {
|
||||||
|
val bottomNavIds = (0 until menu.size()).map { menu.getItem(it).itemId }.toSet()
|
||||||
|
val selectId = if (currentId in bottomNavIds) currentId
|
||||||
|
else if (R.id.nav_more in bottomNavIds) R.id.nav_more
|
||||||
|
else null
|
||||||
|
if (selectId != null) {
|
||||||
|
suppressBottomNavCallback = true
|
||||||
|
binding.bottomNavigation.selectedItemId = selectId
|
||||||
|
suppressBottomNavCallback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyNavLabelVisibility() {
|
||||||
|
val showLabels = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav_show_labels", true)
|
||||||
|
binding.bottomNavigation.labelVisibilityMode =
|
||||||
|
if (showLabels) NavigationBarView.LABEL_VISIBILITY_LABELED
|
||||||
|
else NavigationBarView.LABEL_VISIBILITY_AUTO
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
||||||
|
val dest = fragment ?: when (itemId) {
|
||||||
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
|
R.id.nav_transfer -> TransferFragment()
|
||||||
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
|
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||||
|
R.id.nav_finances -> FinancingFragment()
|
||||||
|
R.id.nav_otp -> OtpFragment()
|
||||||
|
R.id.nav_settings -> SettingsFragment()
|
||||||
|
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||||
|
}
|
||||||
|
show(dest)
|
||||||
|
binding.navigationView.setCheckedItem(itemId)
|
||||||
|
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
||||||
|
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
||||||
|
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
||||||
|
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
||||||
|
if (selectId != null) {
|
||||||
|
suppressBottomNavCallback = true
|
||||||
|
binding.bottomNavigation.selectedItemId = selectId
|
||||||
|
suppressBottomNavCallback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBottomNavVisible(visible: Boolean) {
|
||||||
|
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
if (isBottom) {
|
||||||
|
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setRefreshing(visible: Boolean) {
|
fun setRefreshing(visible: Boolean) {
|
||||||
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
|
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
@@ -194,11 +334,30 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
// Returning from LockActivity — skip the elapsed check and reset state.
|
||||||
|
if (isLocked) {
|
||||||
|
isLocked = false
|
||||||
|
pauseTime = 0L
|
||||||
|
resetAutolockTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||||
|
// QrScannerActivity was in the foreground), lock immediately.
|
||||||
|
if (pauseTime > 0L) {
|
||||||
|
val elapsed = System.currentTimeMillis() - pauseTime
|
||||||
|
val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L)
|
||||||
|
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null
|
||||||
|
if (timeout > 0L && elapsed >= timeout && securitySet) {
|
||||||
|
lock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
resetAutolockTimer()
|
resetAutolockTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
pauseTime = System.currentTimeMillis()
|
||||||
autolockHandler.removeCallbacks(autolockRunnable)
|
autolockHandler.removeCallbacks(autolockRunnable)
|
||||||
autolockHandler.removeCallbacks(warningRunnable)
|
autolockHandler.removeCallbacks(warningRunnable)
|
||||||
countdownTimer?.cancel(); countdownTimer = null
|
countdownTimer?.cancel(); countdownTimer = null
|
||||||
@@ -255,13 +414,23 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
||||||
|
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
|
||||||
|
val eyeItem = menu.findItem(R.id.action_hide_amounts)
|
||||||
|
eyeItem?.isVisible = eyeEnabled
|
||||||
|
val hidden = viewModel.hideAmounts.value ?: false
|
||||||
|
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
if (item.itemId == R.id.action_lock) {
|
if (item.itemId == R.id.action_lock) {
|
||||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
lock()
|
||||||
finish()
|
return true
|
||||||
|
}
|
||||||
|
if (item.itemId == R.id.action_hide_amounts) {
|
||||||
|
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.value = newHidden
|
||||||
|
invalidateOptionsMenu()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
@@ -270,102 +439,235 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
fun relogin() {
|
fun relogin() {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasMib = store.hasMibCredentials()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val hasBml = store.hasBmlCredentials()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
if (!hasMib && !hasBml) {
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
|
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
|
||||||
startActivity(Intent(this, LoginActivity::class.java))
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Immediately drop accounts for logged-out banks from the displayed list
|
// Immediately drop accounts for logged-out logins from the displayed list
|
||||||
val current = viewModel.accounts.value ?: emptyList()
|
val current = viewModel.accounts.value ?: emptyList()
|
||||||
viewModel.accounts.value = current.filter { acc ->
|
viewModel.accounts.value = current.filter { acc ->
|
||||||
if (!hasMib && !acc.profileType.startsWith("BML")) return@filter false
|
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
||||||
if (!hasBml && acc.profileType.startsWith("BML")) return@filter false
|
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
||||||
|
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store)
|
autoRefresh(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun autoRefresh(
|
private fun autoRefresh(store: CredentialStore) {
|
||||||
mibCreds: CredentialStore.MibCredentials?,
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
bmlCreds: CredentialStore.BmlCredentials?,
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
store: CredentialStore
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
) {
|
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
||||||
if (mibCreds == null && bmlCreds == null) return
|
|
||||||
binding.refreshIndicator.visibility = View.VISIBLE
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
// MIB and BML login run in parallel
|
// One async job per MIB login, all run in parallel
|
||||||
val mibJob = mibCreds?.let {
|
val mibJobs = mibLoginIds.mapNotNull { loginId ->
|
||||||
async(Dispatchers.IO) {
|
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
|
||||||
|
loginId to async(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
|
||||||
val flow = MibLoginFlow(prefs)
|
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||||
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
|
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
app.accounts = accounts
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
app.mibSession = flow.lastSession
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
app.mibProfiles = flow.lastProfiles
|
app.mibLoginFlows[loginId] = flow
|
||||||
AccountCache.save(this@HomeActivity, accounts)
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
accounts
|
accounts
|
||||||
} catch (_: Exception) { AccountCache.load(this@HomeActivity) }
|
} catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val bmlJob = bmlCreds?.let {
|
// One async job per BML login, all run in parallel
|
||||||
async(Dispatchers.IO) {
|
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
||||||
val bmlFlow = BmlLoginFlow()
|
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
||||||
val savedToken = store.loadBmlSession()
|
loginId to async(Dispatchers.IO) {
|
||||||
|
val loginTag = "bml_$loginId"
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val savedProfiles = store.loadBmlProfiles(loginId)
|
||||||
|
val allAccounts = mutableListOf<MibAccount>()
|
||||||
|
var anyExpired = savedProfiles.isEmpty()
|
||||||
|
|
||||||
// Try cached token first
|
// Try each saved profile's cached session
|
||||||
if (savedToken != null) {
|
for (profile in savedProfiles) {
|
||||||
try {
|
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||||
val session = BmlSession(savedToken.first, savedToken.second)
|
if (saved != null) {
|
||||||
val accounts = bmlFlow.fetchAccounts(session)
|
try {
|
||||||
val app = application as BasedBankApp
|
val session = BmlSession(saved.first, saved.second)
|
||||||
app.bmlSession = session
|
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||||
app.bmlAccounts = accounts
|
app.bmlSessions[profile.profileId] = session
|
||||||
AccountCache.saveBml(this@HomeActivity, accounts)
|
allAccounts += accounts
|
||||||
return@async Pair(session, accounts)
|
} catch (_: AuthExpiredException) { anyExpired = true
|
||||||
} catch (_: AuthExpiredException) {
|
} catch (_: Exception) { anyExpired = true }
|
||||||
// Token expired — fall through to full login
|
} else {
|
||||||
} catch (_: Exception) {
|
anyExpired = true
|
||||||
// Network or other error — fall through to full login
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full login (token missing or expired)
|
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||||
|
|
||||||
|
// Also try legacy single-profile session token (pre-multi-profile installs)
|
||||||
|
if (savedProfiles.isEmpty()) {
|
||||||
|
val legacyToken = store.loadBmlSession(loginId)
|
||||||
|
if (legacyToken != null) {
|
||||||
|
try {
|
||||||
|
val session = BmlSession(legacyToken.first, legacyToken.second)
|
||||||
|
val accounts = BmlLoginFlow().fetchAccounts(session, loginTag)
|
||||||
|
app.bmlSessions[loginId] = session
|
||||||
|
allAccounts += accounts
|
||||||
|
anyExpired = false
|
||||||
|
} catch (_: AuthExpiredException) { anyExpired = true
|
||||||
|
} catch (_: Exception) { anyExpired = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyExpired || allAccounts.isEmpty()) {
|
||||||
|
// Re-authenticate to refresh personal profile sessions
|
||||||
|
try {
|
||||||
|
val flow = app.bmlFlowFor(loginId)
|
||||||
|
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
|
||||||
|
store.saveBmlProfiles(loginId, profiles)
|
||||||
|
app.bmlProfilesMap[loginId] = profiles
|
||||||
|
|
||||||
|
for (profile in profiles) {
|
||||||
|
if (profile.profileType == "business") {
|
||||||
|
// Can't activate business profiles without user OTP — use cached
|
||||||
|
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
if (allAccounts.none { it.profileId == profile.profileId })
|
||||||
|
allAccounts += cached
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val result = flow.activateProfile(profile, loginTag)
|
||||||
|
if (result is BmlActivationResult.Success) {
|
||||||
|
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||||
|
app.bmlSessions[profile.profileId] = result.session
|
||||||
|
allAccounts.removeAll { it.profileId == profile.profileId }
|
||||||
|
allAccounts += result.accounts
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (allAccounts.none { it.profileId == profile.profileId }) {
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
.filter { it.profileId == profile.profileId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (allAccounts.isEmpty())
|
||||||
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allAccounts.isNotEmpty()) AccountCache.saveBml(this@HomeActivity, loginId, allAccounts)
|
||||||
|
allAccounts as List<MibAccount>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One async job per Fahipay login, all run in parallel
|
||||||
|
val fahipayJobs = fahipayLoginIds.mapNotNull { loginId ->
|
||||||
|
val creds = store.loadFahipayCredentials(loginId) ?: return@mapNotNull null
|
||||||
|
loginId to async(Dispatchers.IO) {
|
||||||
|
val fahipayFlow = FahipayLoginFlow()
|
||||||
|
val deviceUuid = store.getOrCreateFahipayDeviceUuid()
|
||||||
|
val loginTag = "fahipay_$loginId"
|
||||||
|
|
||||||
|
val savedSession = store.loadFahipaySession(loginId)
|
||||||
|
if (savedSession != null) {
|
||||||
|
try {
|
||||||
|
val session = FahipaySession(savedSession.first, savedSession.second)
|
||||||
|
fahipayFlow.setSessionCookie(session.sessionCookie)
|
||||||
|
val balance = fahipayFlow.fetchBalance(session)
|
||||||
|
val profile = fahipayFlow.fetchProfile(session)
|
||||||
|
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
app.fahipaySessions[loginId] = session
|
||||||
|
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||||
|
return@async accounts
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val (session, accounts) = bmlFlow.login(it.username, it.password, it.otpSeed)
|
val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid)
|
||||||
store.saveBmlSession(session.accessToken, session.deviceId)
|
if (step.twoFactorRequired) {
|
||||||
|
return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||||
|
}
|
||||||
|
val authId = step.authId ?: return@async AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||||
|
val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
|
||||||
|
val session = FahipaySession(authId, cookieValue)
|
||||||
|
store.saveFahipaySession(loginId, authId, cookieValue)
|
||||||
|
val profile = fahipayFlow.fetchProfile(session)
|
||||||
|
val balance = fahipayFlow.fetchBalance(session)
|
||||||
|
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
app.bmlSession = session
|
app.fahipaySessions[loginId] = session
|
||||||
app.bmlAccounts = accounts
|
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||||
AccountCache.saveBml(this@HomeActivity, accounts)
|
accounts
|
||||||
Pair(session, accounts)
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
Pair(null, AccountCache.loadBml(this@HomeActivity))
|
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity)
|
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||||
val (bmlSession, bmlAccounts) = bmlJob?.await() ?: Pair(null, AccountCache.loadBml(this@HomeActivity))
|
val mibAccounts = mibResults.flatMap { it.second }
|
||||||
|
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||||
viewModel.accounts.postValue(mibAccounts + bmlAccounts)
|
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||||
binding.refreshIndicator.visibility = View.GONE
|
|
||||||
|
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
if (bmlSession != null) {
|
app.mibAccounts = mibAccounts
|
||||||
refreshBmlContacts(app)
|
AccountCache.save(this@HomeActivity, mibAccounts)
|
||||||
refreshBmlLimits(bmlSession)
|
app.bmlAccounts = bmlAccounts
|
||||||
|
app.fahipayAccounts = fahipayAccounts
|
||||||
|
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
||||||
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
|
||||||
|
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||||
|
for ((loginId, session) in app.mibSessions) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
refreshFinancing(app.mibSession, app.mibProfiles)
|
|
||||||
refreshContacts(app.mibSession, app.mibProfiles)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filters accounts whose profileId the user has hidden in settings. */
|
||||||
|
private fun List<MibAccount>.filterVisibleAccounts(): List<MibAccount> {
|
||||||
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
return filter { acc ->
|
||||||
|
when (acc.bank) {
|
||||||
|
"MIB" -> {
|
||||||
|
val loginId = acc.loginTag.removePrefix("mib_")
|
||||||
|
val hidden = store.getHiddenMibProfileIds(loginId)
|
||||||
|
hidden.isEmpty() || acc.profileId !in hidden
|
||||||
|
}
|
||||||
|
"BML" -> {
|
||||||
|
val loginId = acc.loginTag.removePrefix("bml_")
|
||||||
|
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||||
|
hidden.isEmpty() || acc.profileId !in hidden
|
||||||
|
}
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filters MIB profiles the user has hidden for a given loginId. */
|
||||||
|
private fun List<MibProfile>.filterVisibleProfiles(loginId: String): List<MibProfile> {
|
||||||
|
val hidden = CredentialStore(this@HomeActivity).getHiddenMibProfileIds(loginId)
|
||||||
|
if (hidden.isEmpty()) return this
|
||||||
|
return filter { it.profileId !in hidden }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
||||||
|
fun applyProfileVisibility() {
|
||||||
|
val current = viewModel.accounts.value ?: return
|
||||||
|
viewModel.accounts.value = current.filterVisibleAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshBmlLimits(session: BmlSession) {
|
private fun refreshBmlLimits(session: BmlSession) {
|
||||||
val bmlFlow = BmlLoginFlow()
|
val bmlFlow = BmlLoginFlow()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
@@ -384,20 +686,74 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshBmlContacts(app: BasedBankApp) {
|
private fun refreshBmlContacts(app: BasedBankApp) {
|
||||||
val session = app.bmlSession ?: return
|
if (app.bmlSessions.isEmpty()) return
|
||||||
val bmlFlow = BmlLoginFlow()
|
val store = CredentialStore(this)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val bmlContacts = withContext(Dispatchers.IO) { bmlFlow.fetchContacts(session) }
|
val allBmlContacts = withContext(Dispatchers.IO) {
|
||||||
if (bmlContacts.isNotEmpty()) {
|
store.getBmlLoginIds().flatMap { loginId ->
|
||||||
ContactsCache.saveBml(this@HomeActivity, bmlContacts)
|
val session = app.anyBmlSessionFor(loginId) ?: return@flatMap emptyList()
|
||||||
val mibContacts = viewModel.contacts.value ?: ContactsCache.loadContacts(this@HomeActivity)
|
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
|
||||||
viewModel.contacts.postValue(mergeContacts(mibContacts, bmlContacts))
|
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
|
||||||
|
contacts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allBmlContacts.isNotEmpty()) {
|
||||||
|
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
|
||||||
|
val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
|
||||||
|
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
|
||||||
|
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, allBmlContacts), fahipayContacts))
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { /* keep cached */ }
|
} catch (_: Exception) { /* keep cached */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadAllContacts() {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val store = sh.sar.basedbank.util.CredentialStore(this)
|
||||||
|
// Populate ViewModel from cache immediately if empty
|
||||||
|
if (viewModel.contacts.value.isNullOrEmpty()) {
|
||||||
|
val cached = mergeContacts(
|
||||||
|
mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this, store.getBmlLoginIds())),
|
||||||
|
ContactsCache.loadFahipay(this)
|
||||||
|
)
|
||||||
|
if (cached.isNotEmpty()) viewModel.contacts.value = cached
|
||||||
|
}
|
||||||
|
if (viewModel.contactCategories.value.isNullOrEmpty()) {
|
||||||
|
val cats = ContactsCache.loadCategories(this) + ContactsCache.loadFahipayCategories(this)
|
||||||
|
if (cats.isNotEmpty()) viewModel.contactCategories.value = cats
|
||||||
|
}
|
||||||
|
// Refresh all banks in background
|
||||||
|
for ((loginId, session) in app.mibSessions) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshContacts(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
|
}
|
||||||
|
refreshBmlContacts(app)
|
||||||
|
for ((_, session) in app.fahipaySessions) refreshFahipayContacts(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshFahipayContacts(session: FahipaySession) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val groups = withContext(Dispatchers.IO) {
|
||||||
|
val flow = FahipayLoginFlow()
|
||||||
|
flow.setSessionCookie(session.sessionCookie)
|
||||||
|
flow.fetchContacts(session)
|
||||||
|
}
|
||||||
|
if (groups.isEmpty()) return@launch
|
||||||
|
val contacts = groups.flatMap { it.contacts }
|
||||||
|
val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) }
|
||||||
|
ContactsCache.saveFahipay(this@HomeActivity, contacts, categories)
|
||||||
|
val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
|
||||||
|
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
|
||||||
|
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
|
||||||
|
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), contacts))
|
||||||
|
val mibCategories = ContactsCache.loadCategories(this@HomeActivity)
|
||||||
|
viewModel.contactCategories.postValue(mibCategories + categories)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun mergeContacts(
|
private fun mergeContacts(
|
||||||
mib: List<MibBeneficiary>,
|
mib: List<MibBeneficiary>,
|
||||||
bml: List<MibBeneficiary>
|
bml: List<MibBeneficiary>
|
||||||
@@ -409,10 +765,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
|
private fun refreshContacts(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
||||||
if (session == null || profiles.isEmpty()) return
|
if (profiles.isEmpty()) return
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
||||||
val flow = MibLoginFlow(prefs)
|
|
||||||
val contactsClient = MibContactsClient()
|
val contactsClient = MibContactsClient()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -437,9 +792,12 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
if (allContacts.isNotEmpty()) {
|
if (allContacts.isNotEmpty()) {
|
||||||
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
|
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
|
||||||
val bmlContacts = ContactsCache.loadBml(this@HomeActivity)
|
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
|
||||||
viewModel.contacts.postValue(mergeContacts(allContacts, bmlContacts))
|
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, store.getBmlLoginIds())
|
||||||
viewModel.contactCategories.postValue(allCategories)
|
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
|
||||||
|
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
|
||||||
|
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
|
||||||
|
viewModel.contactCategories.postValue(allCategories + fahipayCategories)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { /* keep cached data */ }
|
} catch (_: Exception) { /* keep cached data */ }
|
||||||
}
|
}
|
||||||
@@ -449,39 +807,76 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val current = viewModel.accounts.value ?: emptyList()
|
val current = viewModel.accounts.value ?: emptyList()
|
||||||
if (src.profileType.startsWith("BML")) {
|
if (src.bank == "FAHIPAY") {
|
||||||
val fresh = withContext(Dispatchers.IO) {
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
val sess = app.bmlSession ?: return@withContext null
|
val sess = app.fahipaySessionFor(src) ?: return@withContext null
|
||||||
try {
|
try {
|
||||||
val accounts = BmlLoginFlow().fetchAccounts(sess)
|
val flow = FahipayLoginFlow()
|
||||||
AccountCache.saveBml(this@HomeActivity, accounts)
|
flow.setSessionCookie(sess.sessionCookie)
|
||||||
app.bmlAccounts = accounts
|
val balance = flow.fetchBalance(sess)
|
||||||
|
val profile = flow.fetchProfile(sess)
|
||||||
|
val loginTag = "fahipay_${profile.profileId}"
|
||||||
|
val accounts = listOf(flow.buildAccount(profile, balance, loginTag))
|
||||||
|
val loginId = src.loginTag.removePrefix("fahipay_")
|
||||||
|
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||||
|
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != src.loginTag } + accounts
|
||||||
accounts
|
accounts
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
val mibOnly = current.filter { !it.profileType.startsWith("BML") }
|
val others = current.filter { it.bank != "FAHIPAY" }
|
||||||
viewModel.accounts.postValue(mibOnly + fresh)
|
viewModel.accounts.postValue(others + fresh)
|
||||||
} else {
|
} else if (src.bank == "BML") {
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
val fresh = withContext(Dispatchers.IO) {
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
val sess = app.mibSession ?: return@withContext null
|
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
|
try {
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag)
|
||||||
try { MibLoginFlow(prefs).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
|
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||||
catch (_: Exception) { null }
|
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
|
||||||
|
app.bmlAccounts = otherBml + accounts
|
||||||
|
accounts
|
||||||
|
} catch (_: Exception) { null }
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
// Replace accounts from this profile only, keep everything else
|
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
|
||||||
val others = current.filter { it.profileId != src.profileId || it.profileType.startsWith("BML") }
|
viewModel.accounts.postValue(otherAccounts + fresh)
|
||||||
val merged = others + fresh
|
} else {
|
||||||
AccountCache.save(this@HomeActivity, merged.filter { !it.profileType.startsWith("BML") })
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
viewModel.accounts.postValue(merged)
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val hidden = store.getHiddenMibProfileIds(loginId)
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val allVisible = profiles.filter { hidden.isEmpty() || it.profileId !in hidden }
|
||||||
|
val sess = app.mibSessions[loginId]
|
||||||
|
if (sess != null && allVisible.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(sess, allVisible, src.loginTag)
|
||||||
|
if (accounts.isNotEmpty()) return@withContext accounts
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
|
||||||
|
try {
|
||||||
|
val flow = MibLoginFlow(store)
|
||||||
|
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||||
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
|
app.mibLoginFlows[loginId] = flow
|
||||||
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
|
accounts.takeIf { it.isNotEmpty() }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
} ?: return@launch
|
||||||
|
// Replace accounts for this MIB login
|
||||||
|
val others = current.filter { it.loginTag != src.loginTag }
|
||||||
|
val newMibAccounts = app.mibAccounts.filter { it.loginTag != src.loginTag } + fresh
|
||||||
|
app.mibAccounts = newMibAccounts
|
||||||
|
AccountCache.save(this@HomeActivity, newMibAccounts)
|
||||||
|
viewModel.accounts.postValue(others + fresh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
|
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
||||||
if (session == null || profiles.isEmpty()) return
|
if (profiles.isEmpty()) return
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
||||||
val flow = MibLoginFlow(prefs)
|
|
||||||
val client = MibFinancingClient()
|
val client = MibFinancingClient()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,4 +16,6 @@ class HomeViewModel : ViewModel() {
|
|||||||
|
|
||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/src/main/java/sh/sar/basedbank/ui/home/MoreFragment.kt
Normal file
37
app/src/main/java/sh/sar/basedbank/ui/home/MoreFragment.kt
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
|
class MoreFragment : Fragment() {
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
|
inflater.inflate(R.layout.fragment_more, container, false)
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val items = NavCustomization.getMoreItems(prefs)
|
||||||
|
val list = view.findViewById<LinearLayout>(R.id.moreList)
|
||||||
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
|
for (item in items) {
|
||||||
|
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
||||||
|
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
|
||||||
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
||||||
|
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
|
||||||
|
list.addView(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_more)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
|
object NavCustomization {
|
||||||
|
|
||||||
|
data class NavItemDef(
|
||||||
|
val id: Int,
|
||||||
|
@DrawableRes val iconRes: Int,
|
||||||
|
@StringRes val titleRes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
|
val ALL_SWAPPABLE = listOf(
|
||||||
|
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts),
|
||||||
|
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts),
|
||||||
|
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer),
|
||||||
|
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
|
||||||
|
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
|
||||||
|
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
|
||||||
|
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
|
||||||
|
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
|
||||||
|
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
|
||||||
|
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
|
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
|
||||||
|
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
|
||||||
|
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
|
prefs.edit()
|
||||||
|
.putInt("bottom_nav_slot_1", slots[0])
|
||||||
|
.putInt("bottom_nav_slot_2", slots[1])
|
||||||
|
.putInt("bottom_nav_slot_3", slots[2])
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
|
prefs.getInt("quick_action_1", R.id.nav_transfer),
|
||||||
|
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
||||||
|
prefs.edit()
|
||||||
|
.putInt("quick_action_1", ids[0])
|
||||||
|
.putInt("quick_action_2", ids[1])
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
||||||
|
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||||
|
val slots = getSlots(prefs).toSet()
|
||||||
|
return ALL_SWAPPABLE.filter { it.id !in slots }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import com.google.android.material.navigation.NavigationView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
|
class NavMoreSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
var onNavigate: ((Int) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
|
inflater.inflate(R.layout.sheet_nav_more, container, false)
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val items = NavCustomization.getMoreItems(prefs)
|
||||||
|
val navView = view.findViewById<NavigationView>(R.id.navMoreView)
|
||||||
|
navView.menu.clear()
|
||||||
|
items.forEachIndexed { i, item ->
|
||||||
|
navView.menu.add(Menu.NONE, item.id, i, item.titleRes).setIcon(item.iconRes)
|
||||||
|
}
|
||||||
|
navView.setNavigationItemSelectedListener { item ->
|
||||||
|
dismiss()
|
||||||
|
onNavigate?.invoke(item.itemId)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,13 +71,15 @@ class OtpFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
val entries = mutableListOf<OtpEntry>()
|
val entries = mutableListOf<OtpEntry>()
|
||||||
store.loadMibCredentials()?.let { creds ->
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val name = store.loadMibFullName()
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
|
val name = store.loadMibFullName(loginId)
|
||||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||||
}
|
}
|
||||||
store.loadBmlCredentials()?.let { creds ->
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val name = store.loadBmlFullName()
|
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||||
entries.add(OtpEntry(if (name != null) "BML · $name" else "BML", creds.otpSeed))
|
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||||
|
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
||||||
}
|
}
|
||||||
|
|
||||||
val adapter = OtpAdapter(entries)
|
val adapter = OtpAdapter(entries)
|
||||||
@@ -87,31 +89,35 @@ class OtpFragment : Fragment() {
|
|||||||
// Fetch real names in background if not yet cached, then refresh labels
|
// Fetch real names in background if not yet cached, then refresh labels
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
var changed = false
|
var changed = false
|
||||||
if (store.loadMibFullName() == null) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
app.mibSession?.let { session ->
|
if (store.loadMibFullName(loginId) == null) {
|
||||||
|
val session = app.mibSessions[loginId] ?: continue
|
||||||
|
val flow = app.mibFlowFor(loginId)
|
||||||
val profile = withContext(Dispatchers.IO) {
|
val profile = withContext(Dispatchers.IO) {
|
||||||
try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null }
|
try { flow.fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (profile != null) {
|
if (profile != null) {
|
||||||
store.saveMibUserProfile(CredentialStore.MibUserProfile(
|
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
|
||||||
fullName = profile.fullName,
|
fullName = profile.fullName,
|
||||||
username = profile.username,
|
username = profile.username,
|
||||||
email = profile.email,
|
email = profile.email,
|
||||||
mobile = profile.mobile,
|
mobile = profile.mobile,
|
||||||
enrolled = profile.enrolled
|
enrolled = profile.enrolled
|
||||||
))
|
))
|
||||||
val idx = entries.indexOfFirst { it.seed == store.loadMibCredentials()?.otpSeed }
|
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
||||||
|
val idx = entries.indexOfFirst { it.seed == seed }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (store.loadBmlFullName() == null) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
app.bmlSession?.let { session ->
|
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
|
||||||
|
val session = app.bmlSessions[loginId] ?: continue
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
|
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
store.saveBmlUserProfile(CredentialStore.BmlUserProfile(
|
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
|
||||||
fullName = info.fullName,
|
fullName = info.fullName,
|
||||||
email = info.email,
|
email = info.email,
|
||||||
mobile = info.mobile,
|
mobile = info.mobile,
|
||||||
@@ -119,7 +125,8 @@ class OtpFragment : Fragment() {
|
|||||||
idCard = info.idCard,
|
idCard = info.idCard,
|
||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
))
|
))
|
||||||
val idx = entries.indexOfFirst { it.seed == store.loadBmlCredentials()?.otpSeed }
|
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
val idx = entries.indexOfFirst { it.seed == seed }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
420
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
420
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.*
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.*
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
class PayMvQrFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentPayMvQrBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var selectedAccount: MibAccount? = null
|
||||||
|
private var generatedBitmap: Bitmap? = null
|
||||||
|
private var generateJob: Job? = null
|
||||||
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
if (qr == null || qr.accountNumber == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = qr.accountNumber,
|
||||||
|
displayName = qr.merchantName ?: qr.accountNumber,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentPayMvQrBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val basePaddingBottom = view.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.updatePadding(bottom = basePaddingBottom + navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
setupDropdown()
|
||||||
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
|
binding.btnShare.isEnabled = false
|
||||||
|
binding.btnSave.isEnabled = false
|
||||||
|
binding.btnShare.setOnClickListener { shareQr() }
|
||||||
|
binding.btnSave.setOnClickListener { saveQr() }
|
||||||
|
binding.btnScanQr.setOnClickListener {
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupDropdown() {
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
val eligible = accounts.filter {
|
||||||
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT"
|
||||||
|
}
|
||||||
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
|
binding.actvAccount.setAdapter(adapter)
|
||||||
|
binding.actvAccount.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
||||||
|
selectedAccount = picked
|
||||||
|
scheduleGenerate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleGenerate() {
|
||||||
|
generateJob?.cancel()
|
||||||
|
generateJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(300)
|
||||||
|
generateQr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun generateQr() {
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
val acquirer = when (account.bank) {
|
||||||
|
"BML" -> "MALBMVMV"
|
||||||
|
"MIB" -> "MADVMVMV"
|
||||||
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
|
else -> "MADVMVMV"
|
||||||
|
}
|
||||||
|
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||||
|
?.replace(",", "")
|
||||||
|
?.toDoubleOrNull()
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { "%.2f".format(it) }
|
||||||
|
|
||||||
|
val ctx = requireContext()
|
||||||
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
|
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
||||||
|
renderQrCard(ctx, account, payload, amountFormatted)
|
||||||
|
}
|
||||||
|
if (_binding == null) return
|
||||||
|
generatedBitmap = bmp
|
||||||
|
binding.tvQrPlaceholder.visibility = View.GONE
|
||||||
|
binding.ivQrCard.setImageBitmap(bmp)
|
||||||
|
binding.ivQrCard.visibility = View.VISIBLE
|
||||||
|
binding.btnShare.isEnabled = true
|
||||||
|
binding.btnSave.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EMV MPQR payload ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildQrPayload(
|
||||||
|
accountNumber: String,
|
||||||
|
accountName: String,
|
||||||
|
acquirer: String,
|
||||||
|
amountStr: String?
|
||||||
|
): String {
|
||||||
|
fun tlv(tag: String, value: String): String {
|
||||||
|
val len = value.length
|
||||||
|
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||||
|
}
|
||||||
|
val format = tlv("00", "01")
|
||||||
|
val poi = tlv("01", "11")
|
||||||
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
|
val sub01 = tlv("01", acquirer)
|
||||||
|
val sub03 = tlv("03", accountNumber)
|
||||||
|
val sub10 = tlv("10", "IPAY")
|
||||||
|
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
||||||
|
val currency = tlv("53", "462")
|
||||||
|
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||||
|
val country = tlv("58", "MV")
|
||||||
|
val name = tlv("59", accountName.take(25))
|
||||||
|
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
|
||||||
|
return prefix + crc16(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crc16(data: String): String {
|
||||||
|
var crc = 0xFFFF
|
||||||
|
for (c in data) {
|
||||||
|
crc = crc xor ((c.code and 0xFF) shl 8)
|
||||||
|
repeat(8) {
|
||||||
|
crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021
|
||||||
|
else (crc shl 1) and 0xFFFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc.toString(16).uppercase().padStart(4, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR card rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun renderQrCard(
|
||||||
|
ctx: Context,
|
||||||
|
account: MibAccount,
|
||||||
|
qrPayload: String,
|
||||||
|
amountStr: String?
|
||||||
|
): Bitmap {
|
||||||
|
val W = 900
|
||||||
|
val H = 1080
|
||||||
|
val outerCorner = 48f
|
||||||
|
val boxBlue = Color.parseColor("#2272B7")
|
||||||
|
val footerBlue = Color.parseColor("#1A5799")
|
||||||
|
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
||||||
|
|
||||||
|
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
// Clip to outer rounded card shape
|
||||||
|
val outerPath = Path()
|
||||||
|
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
||||||
|
canvas.clipPath(outerPath)
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
|
||||||
|
// --- Bank logo top-left ---
|
||||||
|
val logoRes = when (account.bank) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"MIB" -> R.drawable.mib_faisanet_logo
|
||||||
|
else -> R.drawable.fahipay_logo_long
|
||||||
|
}
|
||||||
|
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||||
|
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||||
|
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||||
|
val maxW = 180f; val maxH = 76f
|
||||||
|
val scale = minOf(maxW / nW, maxH / nH)
|
||||||
|
val lW = (nW * scale).toInt()
|
||||||
|
val lH = (nH * scale).toInt()
|
||||||
|
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
||||||
|
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
||||||
|
d.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- "PayMV QR" top-right ---
|
||||||
|
paint.color = Color.parseColor("#1A1A2E")
|
||||||
|
paint.textSize = 36f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.RIGHT
|
||||||
|
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||||
|
|
||||||
|
// --- Blue rounded box ---
|
||||||
|
paint.color = boxBlue
|
||||||
|
paint.textAlign = Paint.Align.LEFT
|
||||||
|
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||||
|
|
||||||
|
// Account name (white, bold, uppercase, auto-scaled to fit)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
val nameText = account.accountBriefName.uppercase()
|
||||||
|
paint.textSize = 36f
|
||||||
|
val maxNameW = boxR - boxL - 48f
|
||||||
|
if (paint.measureText(nameText) > maxNameW) {
|
||||||
|
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
||||||
|
}
|
||||||
|
val nameBaseline = boxT + 68f
|
||||||
|
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
||||||
|
|
||||||
|
// Optional amount below name
|
||||||
|
val qrTopY: Float
|
||||||
|
if (!amountStr.isNullOrBlank()) {
|
||||||
|
paint.textSize = 28f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||||
|
val amtBaseline = nameBaseline + 42f
|
||||||
|
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
||||||
|
qrTopY = amtBaseline + 20f
|
||||||
|
} else {
|
||||||
|
qrTopY = nameBaseline + 26f
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR code — white modules on the same blue as the box background
|
||||||
|
val availH = boxB - qrTopY - 24f
|
||||||
|
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
||||||
|
val qrLeft = ((W - qrPx) / 2).toFloat()
|
||||||
|
try {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||||
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
|
for (y in 0 until qrPx) {
|
||||||
|
for (x in 0 until qrPx) {
|
||||||
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
|
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||||
|
qrBm.recycle()
|
||||||
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
|
// --- Dark blue footer ---
|
||||||
|
paint.color = footerBlue
|
||||||
|
paint.textAlign = Paint.Align.LEFT
|
||||||
|
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.textSize = 32f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
||||||
|
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share / Save ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun shareQr() {
|
||||||
|
val bmp = generatedBitmap ?: return
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val uri = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val dir = File(requireContext().cacheDir, "qr")
|
||||||
|
dir.mkdirs()
|
||||||
|
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
|
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||||
|
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
requireContext(),
|
||||||
|
"${requireContext().packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (uri == null || _binding == null) return@launch
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||||
|
type = "image/png"
|
||||||
|
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveQr() {
|
||||||
|
val bmp = generatedBitmap ?: return
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val saved = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
|
val filename = "${safeName}_PayMV_QR.png"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
|
||||||
|
}
|
||||||
|
val uri = requireContext().contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
|
||||||
|
) ?: return@withContext false
|
||||||
|
requireContext().contentResolver.openOutputStream(uri)?.use {
|
||||||
|
bmp.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||||
|
dir.mkdirs()
|
||||||
|
FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.pay_mv_qr)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class QrAccountAdapter(
|
||||||
|
private val context: Context,
|
||||||
|
private val accounts: List<MibAccount>
|
||||||
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
fun getAccount(position: Int): MibAccount? = accounts.getOrNull(position)
|
||||||
|
|
||||||
|
override fun getCount() = accounts.size
|
||||||
|
override fun getItem(position: Int) = accounts.getOrNull(position)
|
||||||
|
override fun getItemId(position: Int) = position.toLong()
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||||
|
getDropDownView(position, convertView, parent)
|
||||||
|
|
||||||
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val acc = accounts[position]
|
||||||
|
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||||
|
convertView.tag as ItemAccountDropdownBinding
|
||||||
|
} else {
|
||||||
|
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
|
.also { it.root.tag = it }
|
||||||
|
}
|
||||||
|
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||||
|
b.tvDropdownBalance.text = ""
|
||||||
|
b.root.alpha = 1f
|
||||||
|
return b.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(c: CharSequence?) =
|
||||||
|
FilterResults().apply { values = accounts; count = accounts.size }
|
||||||
|
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||||
|
override fun convertResultToString(r: Any?) =
|
||||||
|
(r as? MibAccount)?.let {
|
||||||
|
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||||
|
"$prefix${it.accountBriefName}"
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,16 @@ import android.content.pm.PackageManager
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import androidx.camera.core.Preview
|
import androidx.camera.core.Preview
|
||||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||||
@@ -19,12 +24,14 @@ import androidx.camera.core.resolutionselector.ResolutionSelector
|
|||||||
import androidx.camera.core.resolutionselector.ResolutionStrategy
|
import androidx.camera.core.resolutionselector.ResolutionStrategy
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import de.markusfisch.android.zxingcpp.ZxingCpp
|
import de.markusfisch.android.zxingcpp.ZxingCpp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
|
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -45,9 +52,11 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
textMode = ZxingCpp.TextMode.PLAIN
|
textMode = ZxingCpp.TextMode.PLAIN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private var cameraStarted = false
|
||||||
|
|
||||||
private val permissionLauncher = registerForActivityResult(
|
private val permissionLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted -> if (granted) startCamera() else finish() }
|
) { granted -> if (granted) startCamera() else showPermissionRationale() }
|
||||||
|
|
||||||
private val pickImageLauncher = registerForActivityResult(
|
private val pickImageLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.GetContent()
|
ActivityResultContracts.GetContent()
|
||||||
@@ -78,8 +87,23 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
// Black camera background — always use light (white) system bar icons
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = false
|
||||||
|
isAppearanceLightNavigationBars = false
|
||||||
|
}
|
||||||
|
val originalBtnMarginBottom = (48 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.btnContainer) { view, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
(view.layoutParams as android.widget.FrameLayout.LayoutParams).also {
|
||||||
|
it.bottomMargin = originalBtnMarginBottom + bars.bottom
|
||||||
|
view.layoutParams = it
|
||||||
|
}
|
||||||
|
insets
|
||||||
|
}
|
||||||
binding.btnCancel.setOnClickListener { finish() }
|
binding.btnCancel.setOnClickListener { finish() }
|
||||||
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
||||||
|
|
||||||
@@ -89,7 +113,30 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
else permissionLauncher.launch(Manifest.permission.CAMERA)
|
else permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (!cameraStarted && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
) startCamera()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPermissionRationale() {
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.qr_camera_permission_title)
|
||||||
|
.setMessage(R.string.qr_camera_permission_message)
|
||||||
|
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
||||||
|
.setPositiveButton(R.string.go_to_settings) { _, _ ->
|
||||||
|
startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", packageName, null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun startCamera() {
|
private fun startCamera() {
|
||||||
|
cameraStarted = true
|
||||||
val future = ProcessCameraProvider.getInstance(this)
|
val future = ProcessCameraProvider.getInstance(this)
|
||||||
future.addListener({
|
future.addListener({
|
||||||
val provider = try {
|
val provider = try {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
class SettingsAppearanceFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsAppearanceBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private val slots = mutableListOf<Int>()
|
||||||
|
private val quickActions = mutableListOf<Int>()
|
||||||
|
private lateinit var slotAdapter: NavItemAdapter
|
||||||
|
private lateinit var quickActionAdapter: NavItemAdapter
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// Navigation mode
|
||||||
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
|
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||||
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||||
|
(activity as? HomeActivity)?.applyNavMode()
|
||||||
|
updateShortcutsVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick actions
|
||||||
|
quickActions.clear()
|
||||||
|
quickActions.addAll(NavCustomization.getQuickActions(prefs))
|
||||||
|
quickActionAdapter = NavItemAdapter(quickActions) {
|
||||||
|
NavCustomization.saveQuickActions(prefs, quickActions)
|
||||||
|
}
|
||||||
|
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
|
||||||
|
|
||||||
|
// Bottom bar shortcuts
|
||||||
|
slots.clear()
|
||||||
|
slots.addAll(NavCustomization.getSlots(prefs))
|
||||||
|
slotAdapter = NavItemAdapter(slots) {
|
||||||
|
NavCustomization.saveSlots(prefs, slots)
|
||||||
|
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||||
|
}
|
||||||
|
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
|
||||||
|
// Show labels toggle
|
||||||
|
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||||
|
binding.switchShowLabels.isChecked = showLabels
|
||||||
|
binding.switchShowLabels.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.edit().putBoolean("bottom_nav_show_labels", checked).apply()
|
||||||
|
(activity as? HomeActivity)?.applyNavLabelVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShortcutsVisibility()
|
||||||
|
|
||||||
|
// Theme
|
||||||
|
val saved = prefs.getString("theme", "system")
|
||||||
|
binding.themeToggle.check(when (saved) {
|
||||||
|
"light" -> R.id.btnThemeLight
|
||||||
|
"dark" -> R.id.btnThemeDark
|
||||||
|
else -> R.id.btnThemeSystem
|
||||||
|
})
|
||||||
|
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val (key, mode) = when (checkedId) {
|
||||||
|
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
prefs.edit().putString("theme", key).apply()
|
||||||
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language
|
||||||
|
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
||||||
|
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
|
||||||
|
binding.languageToggle.check(if (currentLang == "dv") R.id.btnLangDhivehi else R.id.btnLangEnglish)
|
||||||
|
binding.languageToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val tag = if (checkedId == R.id.btnLangDhivehi) "dv" else "en"
|
||||||
|
setApplicationLocales(LocaleListCompat.forLanguageTags(tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupNavItemRecyclerView(
|
||||||
|
rv: RecyclerView,
|
||||||
|
adapter: NavItemAdapter,
|
||||||
|
items: MutableList<Int>
|
||||||
|
) {
|
||||||
|
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
|
rv.adapter = adapter
|
||||||
|
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||||
|
ItemTouchHelper.START or ItemTouchHelper.END, 0
|
||||||
|
) {
|
||||||
|
override fun onMove(
|
||||||
|
rv: RecyclerView,
|
||||||
|
from: RecyclerView.ViewHolder,
|
||||||
|
to: RecyclerView.ViewHolder
|
||||||
|
): Boolean {
|
||||||
|
val fromPos = from.adapterPosition
|
||||||
|
val toPos = to.adapterPosition
|
||||||
|
Collections.swap(items, fromPos, toPos)
|
||||||
|
adapter.notifyItemMoved(fromPos, toPos)
|
||||||
|
adapter.onSave()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
|
||||||
|
}).attachToRecyclerView(rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateShortcutsVisibility() {
|
||||||
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
|
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||||
|
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||||
|
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||||
|
val listLayout = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
|
||||||
|
val rows = available.map { item ->
|
||||||
|
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
|
||||||
|
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
|
||||||
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
||||||
|
listLayout.addView(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scroll = ScrollView(ctx).apply { addView(listLayout) }
|
||||||
|
var dialog: androidx.appcompat.app.AlertDialog? = null
|
||||||
|
dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.settings_bottom_bar_select)
|
||||||
|
.setView(scroll)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
available.forEachIndexed { i, item ->
|
||||||
|
rows[i].setOnClickListener {
|
||||||
|
items[slotIndex] = item.id
|
||||||
|
adapter.onSave()
|
||||||
|
adapter.notifyItemChanged(slotIndex)
|
||||||
|
dialog?.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class NavItemAdapter(
|
||||||
|
val items: MutableList<Int>,
|
||||||
|
val onSave: () -> Unit
|
||||||
|
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
val ivNavIcon: ImageView = view.findViewById(R.id.ivNavIcon)
|
||||||
|
val tvNavLabel: TextView = view.findViewById(R.id.tvNavLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_nav_slot, parent, false)
|
||||||
|
val itemWidth = if (parent.measuredWidth > 0) parent.measuredWidth / items.size
|
||||||
|
else RecyclerView.LayoutParams.WRAP_CONTENT
|
||||||
|
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||||
|
return VH(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
|
||||||
|
holder.ivNavIcon.setImageResource(def.iconRes)
|
||||||
|
holder.tvNavLabel.setText(def.titleRes)
|
||||||
|
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_appearance)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,301 +1,51 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
|
||||||
import androidx.biometric.BiometricManager
|
|
||||||
import androidx.core.os.LocaleListCompat
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import android.content.Intent
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
|
||||||
import sh.sar.basedbank.BasedBankApp
|
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsBinding
|
|
||||||
import sh.sar.basedbank.ui.onboarding.SecuritySetupFragment
|
|
||||||
import sh.sar.basedbank.util.AccountCache
|
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
|
||||||
import sh.sar.basedbank.util.FinancingCache
|
|
||||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
|
||||||
|
|
||||||
class SettingsFragment : Fragment() {
|
class SettingsFragment : Fragment() {
|
||||||
|
|
||||||
private var _binding: FragmentSettingsBinding? = null
|
private data class SettingsItem(
|
||||||
private val binding get() = _binding!!
|
@DrawableRes val icon: Int,
|
||||||
|
@StringRes val title: Int,
|
||||||
|
val dest: () -> Fragment
|
||||||
|
)
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
private val items = listOf(
|
||||||
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
|
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins) { SettingsLoginsFragment() },
|
||||||
return binding.root
|
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance) { SettingsAppearanceFragment() },
|
||||||
}
|
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security) { SettingsSecurityFragment() },
|
||||||
|
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage) { SettingsStorageFragment() },
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
|
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val list = view.findViewById<LinearLayout>(R.id.settingsList)
|
||||||
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
// Theme
|
for (item in items) {
|
||||||
val saved = prefs.getString("theme", "system")
|
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
||||||
val initialId = when (saved) {
|
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
|
||||||
"light" -> R.id.btnThemeLight
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
|
||||||
"dark" -> R.id.btnThemeDark
|
row.setOnClickListener {
|
||||||
else -> R.id.btnThemeSystem
|
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
|
||||||
}
|
|
||||||
binding.themeToggle.check(initialId)
|
|
||||||
|
|
||||||
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
|
||||||
val (key, mode) = when (checkedId) {
|
|
||||||
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
|
||||||
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
|
||||||
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
|
||||||
}
|
}
|
||||||
prefs.edit().putString("theme", key).apply()
|
list.addView(row)
|
||||||
AppCompatDelegate.setDefaultNightMode(mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Language
|
|
||||||
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
|
||||||
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
|
|
||||||
binding.languageToggle.check(
|
|
||||||
if (currentLang == "dv") R.id.btnLangDhivehi else R.id.btnLangEnglish
|
|
||||||
)
|
|
||||||
|
|
||||||
binding.languageToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
|
||||||
val tag = if (checkedId == R.id.btnLangDhivehi) "dv" else "en"
|
|
||||||
setApplicationLocales(LocaleListCompat.forLanguageTags(tag))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-lock
|
|
||||||
val savedTimeout = prefs.getLong("autolock_timeout", 60_000L)
|
|
||||||
binding.autolockToggle.check(when (savedTimeout) {
|
|
||||||
0L -> R.id.btnAutolockOff
|
|
||||||
30_000L -> R.id.btnAutolock30s
|
|
||||||
180_000L -> R.id.btnAutolock3m
|
|
||||||
300_000L -> R.id.btnAutolock5m
|
|
||||||
else -> R.id.btnAutolock1m
|
|
||||||
})
|
|
||||||
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
|
||||||
val timeout = when (checkedId) {
|
|
||||||
R.id.btnAutolockOff -> 0L
|
|
||||||
R.id.btnAutolock30s -> 30_000L
|
|
||||||
R.id.btnAutolock3m -> 180_000L
|
|
||||||
R.id.btnAutolock5m -> 300_000L
|
|
||||||
else -> 60_000L
|
|
||||||
}
|
|
||||||
prefs.edit().putLong("autolock_timeout", timeout).apply()
|
|
||||||
(activity as? HomeActivity)?.resetAutolockTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change lock
|
|
||||||
binding.btnChangeLock.setOnClickListener {
|
|
||||||
(requireActivity() as HomeActivity).showWithBackStack(
|
|
||||||
SecuritySetupFragment.newInstance(changeMode = true)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Biometrics toggle — only show if device supports it
|
|
||||||
val canUseBiometrics = BiometricManager.from(requireContext())
|
|
||||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
|
||||||
if (canUseBiometrics) {
|
|
||||||
binding.rowBiometrics.visibility = View.VISIBLE
|
|
||||||
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
|
|
||||||
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add account
|
|
||||||
binding.btnAddAccount.setOnClickListener {
|
|
||||||
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear cache
|
|
||||||
binding.btnClearCache.setOnClickListener {
|
|
||||||
val ctx = requireContext()
|
|
||||||
clearAllCaches(ctx)
|
|
||||||
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.nav_settings)
|
requireActivity().title = getString(R.string.nav_settings)
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
if (binding.rowBiometrics.visibility == View.VISIBLE) {
|
|
||||||
binding.switchBiometrics.isChecked = prefs.getBoolean("biometrics_enabled", false)
|
|
||||||
}
|
|
||||||
buildLoginsSection()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
super.onDestroyView()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Logins section ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private fun buildLoginsSection() {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val store = CredentialStore(ctx)
|
|
||||||
val container = binding.loginsContainer
|
|
||||||
container.removeAllViews()
|
|
||||||
|
|
||||||
val hasMib = store.hasMibCredentials()
|
|
||||||
val hasBml = store.hasBmlCredentials()
|
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (hasMib || hasBml) View.VISIBLE else View.GONE
|
|
||||||
|
|
||||||
if (hasMib) {
|
|
||||||
val profile = store.loadMibUserProfile()
|
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
|
||||||
val profileNames = AccountCache.load(ctx)
|
|
||||||
.map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
|
||||||
addLoginRow(container, R.drawable.mib_faisanet_logo, displayName) {
|
|
||||||
showLoginDetails(
|
|
||||||
title = getString(R.string.mib_name),
|
|
||||||
details = buildString {
|
|
||||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
|
||||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
|
|
||||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
|
|
||||||
if (profileNames.isNotEmpty()) {
|
|
||||||
appendLine()
|
|
||||||
appendLine(getString(R.string.login_detail_profiles))
|
|
||||||
profileNames.forEach { appendLine(" • $it") }
|
|
||||||
}
|
|
||||||
}.trim(),
|
|
||||||
onLogout = { confirmLogout(getString(R.string.mib_name)) { logoutMib(store) } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasBml) {
|
|
||||||
val profile = store.loadBmlUserProfile()
|
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
|
||||||
val profileNames = AccountCache.loadBml(ctx)
|
|
||||||
.map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
|
||||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
|
||||||
showLoginDetails(
|
|
||||||
title = getString(R.string.bml_name),
|
|
||||||
details = buildString {
|
|
||||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
|
||||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
|
|
||||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
|
|
||||||
if (!profile?.customerId.isNullOrBlank()) appendLine("${getString(R.string.login_detail_customer_id)}: ${profile!!.customerId}")
|
|
||||||
if (!profile?.idCard.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.idCard}")
|
|
||||||
if (profileNames.isNotEmpty()) {
|
|
||||||
appendLine()
|
|
||||||
appendLine(getString(R.string.login_detail_profiles))
|
|
||||||
profileNames.forEach { appendLine(" • $it") }
|
|
||||||
}
|
|
||||||
}.trim(),
|
|
||||||
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store) } }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun addLoginRow(
|
|
||||||
container: LinearLayout,
|
|
||||||
logoRes: Int,
|
|
||||||
displayName: String,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
val dp = ctx.resources.displayMetrics.density
|
|
||||||
|
|
||||||
val row = LinearLayout(ctx).apply {
|
|
||||||
orientation = LinearLayout.HORIZONTAL
|
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
|
||||||
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
|
||||||
isClickable = true
|
|
||||||
isFocusable = true
|
|
||||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
|
||||||
background = ta.getDrawable(0)
|
|
||||||
ta.recycle()
|
|
||||||
setOnClickListener { onClick() }
|
|
||||||
}
|
|
||||||
|
|
||||||
val logo = ImageView(ctx).apply {
|
|
||||||
setImageResource(logoRes)
|
|
||||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
|
||||||
layoutParams = LinearLayout.LayoutParams((36 * dp).toInt(), (36 * dp).toInt()).apply {
|
|
||||||
marginEnd = (12 * dp).toInt()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val tvName = TextView(ctx).apply {
|
|
||||||
text = displayName
|
|
||||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
|
||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
row.addView(logo)
|
|
||||||
row.addView(tvName)
|
|
||||||
container.addView(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
|
||||||
.setTitle(title)
|
|
||||||
.apply { if (details.isNotBlank()) setMessage(details) }
|
|
||||||
.setPositiveButton(R.string.close, null)
|
|
||||||
.setNegativeButton(R.string.settings_logout) { _, _ -> onLogout() }
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun confirmLogout(bankName: String, onConfirm: () -> Unit) {
|
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
|
||||||
.setTitle(getString(R.string.settings_logout_confirm_title, bankName))
|
|
||||||
.setMessage(R.string.settings_logout_confirm_message)
|
|
||||||
.setPositiveButton(R.string.settings_logout) { _, _ -> onConfirm() }
|
|
||||||
.setNegativeButton(R.string.cancel, null)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logoutMib(store: CredentialStore) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
store.clearMibCredentials()
|
|
||||||
ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply()
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
app.accounts = emptyList()
|
|
||||||
app.mibSession = null
|
|
||||||
app.mibProfiles = emptyList()
|
|
||||||
clearAllCaches(ctx)
|
|
||||||
(activity as HomeActivity).relogin()
|
|
||||||
buildLoginsSection()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun logoutBml(store: CredentialStore) {
|
|
||||||
val ctx = requireContext()
|
|
||||||
store.clearBmlCredentials()
|
|
||||||
store.clearBmlSession()
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
app.bmlSession = null
|
|
||||||
app.bmlAccounts = emptyList()
|
|
||||||
clearAllCaches(ctx)
|
|
||||||
(activity as HomeActivity).relogin()
|
|
||||||
buildLoginsSection()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearAllCaches(ctx: Context) {
|
|
||||||
AccountCache.clear(ctx)
|
|
||||||
ContactsCache.clear(ctx)
|
|
||||||
FinancingCache.clear(ctx)
|
|
||||||
ForeignLimitsCache.clear(ctx)
|
|
||||||
RecentsCache.clear(ctx)
|
|
||||||
TransactionCache.clearAll(ctx)
|
|
||||||
ContactImageCache.clearAll(ctx)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
|
import sh.sar.basedbank.util.AccountCache
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
|
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
|
||||||
|
class SettingsLoginsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsLoginsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding.btnAddAccount.setOnClickListener {
|
||||||
|
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_logins)
|
||||||
|
buildLoginsSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLoginsSection() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val store = CredentialStore(ctx)
|
||||||
|
val container = binding.loginsContainer
|
||||||
|
container.removeAllViews()
|
||||||
|
|
||||||
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
|
|
||||||
|
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
for (loginId in mibLoginIds) {
|
||||||
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
|
val mibProfiles = store.loadMibProfiles(loginId)
|
||||||
|
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
||||||
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (loginId in bmlLoginIds) {
|
||||||
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
|
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||||
|
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||||
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (loginId in fahipayLoginIds) {
|
||||||
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
|
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||||
|
showLoginDetails(
|
||||||
|
title = getString(R.string.fahipay_name),
|
||||||
|
details = buildString {
|
||||||
|
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||||
|
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
|
||||||
|
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
|
||||||
|
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}")
|
||||||
|
}.trim(),
|
||||||
|
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
||||||
|
isClickable = true; isFocusable = true
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
val logo = ImageView(ctx).apply {
|
||||||
|
setImageResource(logoRes)
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||||
|
layoutParams = LinearLayout.LayoutParams((36 * dp).toInt(), (36 * dp).toInt()).apply { marginEnd = (12 * dp).toInt() }
|
||||||
|
}
|
||||||
|
val tvName = TextView(ctx).apply {
|
||||||
|
text = displayName
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
row.addView(logo); row.addView(tvName)
|
||||||
|
container.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showMibLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.MibUserProfile?,
|
||||||
|
mibProfiles: List<MibProfile>
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||||
|
val hidden = originalHidden.toMutableSet()
|
||||||
|
|
||||||
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (16 * dp).toInt()
|
||||||
|
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||||
|
}
|
||||||
|
scroll.addView(container)
|
||||||
|
|
||||||
|
// Account info lines
|
||||||
|
listOfNotNull(
|
||||||
|
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||||
|
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
|
||||||
|
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" }
|
||||||
|
).forEach { line ->
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = line
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mibProfiles.isNotEmpty()) {
|
||||||
|
if (profile != null) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.login_detail_profiles)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
|
||||||
|
val toggleRows = mibProfiles.map { p ->
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.name
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
})
|
||||||
|
if (p.cifType.isNotBlank()) {
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.cifType
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
p to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
// Disable the sole remaining visible toggle so it can't be turned off
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.mib_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.setPositiveButton(R.string.save, null) // null — set manually after show()
|
||||||
|
.setNeutralButton(R.string.close, null)
|
||||||
|
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.mib_name)) { logoutMib(store, loginId) }
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
store.setHiddenMibProfileIds(loginId, hidden)
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.relogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBmlLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.BmlUserProfile?,
|
||||||
|
bmlProfiles: List<BmlProfile>
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val originalHidden = store.getHiddenBmlProfileIds(loginId)
|
||||||
|
val hidden = originalHidden.toMutableSet()
|
||||||
|
|
||||||
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (16 * dp).toInt()
|
||||||
|
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||||
|
}
|
||||||
|
scroll.addView(container)
|
||||||
|
|
||||||
|
listOfNotNull(
|
||||||
|
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||||
|
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: $it" },
|
||||||
|
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $it" },
|
||||||
|
profile?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: $it" },
|
||||||
|
profile?.idCard?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: $it" }
|
||||||
|
).forEach { line ->
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = line
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bmlProfiles.isNotEmpty()) {
|
||||||
|
if (profile != null) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.login_detail_profiles)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleRows = bmlProfiles.map { p ->
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.name
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
})
|
||||||
|
if (p.type.isNotBlank()) {
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.type
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
p to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||||
|
toggleRows.forEach { (_, toggle) ->
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.bml_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.setPositiveButton(R.string.save, null)
|
||||||
|
.setNeutralButton(R.string.close, null)
|
||||||
|
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) }
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.applyProfileVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(title)
|
||||||
|
.apply { if (details.isNotBlank()) setMessage(details) }
|
||||||
|
.setPositiveButton(R.string.close, null)
|
||||||
|
.setNegativeButton(R.string.settings_logout) { _, _ -> onLogout() }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmLogout(bankName: String, onConfirm: () -> Unit) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(getString(R.string.settings_logout_confirm_title, bankName))
|
||||||
|
.setMessage(R.string.settings_logout_confirm_message)
|
||||||
|
.setPositiveButton(R.string.settings_logout) { _, _ -> onConfirm() }
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logoutMib(store: CredentialStore, loginId: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
store.clearMibCredentials(loginId)
|
||||||
|
ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.mibSessions.remove(loginId)
|
||||||
|
app.mibProfilesMap.remove(loginId)
|
||||||
|
app.mibLoginFlows.remove(loginId)
|
||||||
|
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
(activity as HomeActivity).relogin()
|
||||||
|
buildLoginsSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logoutBml(store: CredentialStore, loginId: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
// Remove all per-profile sessions for this login from the in-memory map
|
||||||
|
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
||||||
|
profiles.forEach { app.bmlSessions.remove(it.profileId) }
|
||||||
|
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||||
|
store.clearBmlCredentials(loginId)
|
||||||
|
app.bmlProfilesMap.remove(loginId)
|
||||||
|
app.bmlLoginFlows.remove(loginId)
|
||||||
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
(activity as HomeActivity).relogin()
|
||||||
|
buildLoginsSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
store.clearFahipayCredentials(loginId)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.fahipaySessions.remove(loginId)
|
||||||
|
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != "fahipay_$loginId" }
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
(activity as HomeActivity).relogin()
|
||||||
|
buildLoginsSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAllCaches(ctx: Context) {
|
||||||
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
|
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
||||||
|
import sh.sar.basedbank.ui.onboarding.SecuritySetupFragment
|
||||||
|
|
||||||
|
class SettingsSecurityFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsSecurityBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentSettingsSecurityBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// Change lock
|
||||||
|
binding.btnChangeLock.setOnClickListener {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(
|
||||||
|
SecuritySetupFragment.newInstance(changeMode = true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biometrics
|
||||||
|
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
if (canUseBiometrics) {
|
||||||
|
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
binding.switchBiometrics.isChecked = unlockEnabled
|
||||||
|
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
|
||||||
|
|
||||||
|
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = isChecked
|
||||||
|
if (!isChecked) {
|
||||||
|
binding.switchBiometricsTransfer.isChecked = false
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||||
|
binding.switchBiometrics.isEnabled = false
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-lock
|
||||||
|
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
|
||||||
|
0L -> R.id.btnAutolockOff
|
||||||
|
30_000L -> R.id.btnAutolock30s
|
||||||
|
180_000L -> R.id.btnAutolock3m
|
||||||
|
300_000L -> R.id.btnAutolock5m
|
||||||
|
else -> R.id.btnAutolock1m
|
||||||
|
})
|
||||||
|
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val timeout = when (checkedId) {
|
||||||
|
R.id.btnAutolockOff -> 0L
|
||||||
|
R.id.btnAutolock30s -> 30_000L
|
||||||
|
R.id.btnAutolock3m -> 180_000L
|
||||||
|
R.id.btnAutolock5m -> 300_000L
|
||||||
|
else -> 60_000L
|
||||||
|
}
|
||||||
|
prefs.edit().putLong("autolock_timeout", timeout).apply()
|
||||||
|
(activity as? HomeActivity)?.resetAutolockTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide sensitive information (enables/disables the eye icon in toolbar)
|
||||||
|
val viewModel = (requireActivity() as HomeActivity).let {
|
||||||
|
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
|
||||||
|
}
|
||||||
|
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
|
||||||
|
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
|
||||||
|
if (!isChecked) viewModel.hideAmounts.value = false
|
||||||
|
requireActivity().invalidateOptionsMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block screenshots
|
||||||
|
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||||
|
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||||
|
applyFlagSecure(blockScreenshots)
|
||||||
|
binding.switchBlockScreenshots.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("block_screenshots", isChecked).apply()
|
||||||
|
applyFlagSecure(isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_privacy_security)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFlagSecure(enabled: Boolean) {
|
||||||
|
val win = activity?.window ?: return
|
||||||
|
if (enabled) win.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
else win.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
|
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
|
||||||
|
import sh.sar.basedbank.util.AccountCache
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
|
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
|
||||||
|
class SettingsStorageFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsStorageBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
binding.btnClearCache.setOnClickListener {
|
||||||
|
val ctx = requireContext()
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_storage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearAllCaches(ctx: Context) {
|
||||||
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
|
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
|||||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||||
|
|
||||||
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
|
/** Adapter for Transaction History — date-grouped, shows account name in secondary line. */
|
||||||
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
@@ -24,7 +24,16 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
|
|
||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
@@ -34,6 +43,14 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateIconUrl(url: String, bitmap: Bitmap) {
|
||||||
|
iconUrlCache[url] = bitmap
|
||||||
|
displayItems.forEachIndexed { i, item ->
|
||||||
|
if (item is Item.Trx && item.transaction.iconUrl == url)
|
||||||
|
notifyItemChanged(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var _showLoadingFooter = false
|
private var _showLoadingFooter = false
|
||||||
var showLoadingFooter: Boolean
|
var showLoadingFooter: Boolean
|
||||||
get() = _showLoadingFooter
|
get() = _showLoadingFooter
|
||||||
@@ -103,7 +120,9 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
val name = trx.counterpartyName ?: trx.description
|
val name = trx.counterpartyName ?: trx.description
|
||||||
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
val bitmap = trx.counterpartyName?.let { imageCache[it] }
|
val iconBitmap = trx.iconUrl?.let { iconUrlCache[it] }
|
||||||
|
val contactBitmap = if (iconBitmap == null) trx.counterpartyName?.let { imageCache[it] } else null
|
||||||
|
val bitmap = iconBitmap ?: contactBitmap
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
|
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
|
||||||
rd.isCircular = true
|
rd.isCircular = true
|
||||||
@@ -117,22 +136,28 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
b.fvAvatar.background = circle
|
b.fvAvatar.background = circle
|
||||||
b.tvInitial.visibility = View.VISIBLE
|
b.tvInitial.visibility = View.VISIBLE
|
||||||
b.tvInitial.text = initial
|
b.tvInitial.text = initial
|
||||||
if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
|
if (trx.iconUrl != null) onIconUrlNeeded?.invoke(trx.iconUrl)
|
||||||
|
else if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
|
||||||
}
|
}
|
||||||
b.tvDescription.text = trx.description
|
b.tvDescription.text = trx.description
|
||||||
|
|
||||||
// Show account name in secondary line for Transfer History
|
// Show account name in secondary line for Transaction History
|
||||||
b.tvCounterparty.text = trx.accountDisplayName
|
b.tvCounterparty.text = trx.accountDisplayName
|
||||||
b.tvCounterparty.visibility = View.VISIBLE
|
b.tvCounterparty.visibility = View.VISIBLE
|
||||||
|
|
||||||
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
||||||
|
|
||||||
val sign = if (isCredit) "+" else "-"
|
if (hideAmounts) {
|
||||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||||
b.tvAmount.setTextColor(
|
} else {
|
||||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
val sign = if (isCredit) "+" else "-"
|
||||||
)
|
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||||
|
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||||
|
b.tvAmount.setTextColor(
|
||||||
|
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
b.root.setOnClickListener { showDetail(trx) }
|
b.root.setOnClickListener { showDetail(trx) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,17 @@ import android.view.ViewGroup
|
|||||||
import android.widget.BaseAdapter
|
import android.widget.BaseAdapter
|
||||||
import android.widget.Filter
|
import android.widget.Filter
|
||||||
import android.widget.Filterable
|
import android.widget.Filterable
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -29,6 +37,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||||
@@ -40,10 +50,13 @@ import sh.sar.basedbank.api.mib.MibTransferResult
|
|||||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import sh.sar.basedbank.util.RecentPick
|
import sh.sar.basedbank.util.RecentPick
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class TransferFragment : Fragment() {
|
class TransferFragment : Fragment() {
|
||||||
@@ -53,14 +66,22 @@ class TransferFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private var selectedAccount: MibAccount? = null
|
private var selectedAccount: MibAccount? = null
|
||||||
private val session get() = (requireActivity().application as BasedBankApp).mibSession
|
private val session get() = selectedAccount
|
||||||
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession
|
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
|
||||||
|
?: (requireActivity().application as BasedBankApp).anyMibSession()
|
||||||
|
private fun bmlSessionFor(account: MibAccount?) =
|
||||||
|
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
|
||||||
|
?: (requireActivity().application as BasedBankApp).anyBmlSession()
|
||||||
|
|
||||||
// Resolved recipient info — set after successful lookup or prefill
|
// Resolved recipient info — set after successful lookup or prefill
|
||||||
private var resolvedAccountNumber = ""
|
private var resolvedAccountNumber = ""
|
||||||
private var resolvedRecipientName = ""
|
private var resolvedRecipientName = ""
|
||||||
private var resolvedBankName = ""
|
private var resolvedBankName = ""
|
||||||
|
|
||||||
|
// Selected Fahipay service when source is Fahipay and destination is a phone number
|
||||||
|
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||||
|
private var selectedFahipayService: String? = null
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
@@ -80,6 +101,13 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_SUBTITLE = "contact_subtitle"
|
private const val ARG_SUBTITLE = "contact_subtitle"
|
||||||
private const val ARG_COLOR = "contact_color"
|
private const val ARG_COLOR = "contact_color"
|
||||||
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
||||||
|
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||||
|
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
||||||
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
|
|
||||||
|
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
|
||||||
|
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||||
|
}
|
||||||
|
|
||||||
fun newInstance(
|
fun newInstance(
|
||||||
accountNumber: String,
|
accountNumber: String,
|
||||||
@@ -96,6 +124,22 @@ class TransferFragment : Fragment() {
|
|||||||
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
|
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun newInstanceFromQr(
|
||||||
|
accountNumber: String,
|
||||||
|
displayName: String,
|
||||||
|
amount: String?,
|
||||||
|
remarks: String?
|
||||||
|
) = TransferFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
|
putString(ARG_NAME, displayName)
|
||||||
|
putString(ARG_SUBTITLE, accountNumber)
|
||||||
|
putString(ARG_COLOR, "#607D8B")
|
||||||
|
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||||
|
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
@@ -109,15 +153,11 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||||
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
|
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
|
||||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||||
if (bundle.getBoolean(ContactPickerSheetFragment.KEY_SKIP_LOOKUP, false)) {
|
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||||
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
||||||
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
||||||
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
|
||||||
} else {
|
|
||||||
prefillToFromContact(accountNumber, label)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnPickContact.setOnClickListener {
|
binding.btnPickContact.setOnClickListener {
|
||||||
@@ -129,9 +169,12 @@ class TransferFragment : Fragment() {
|
|||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
binding.btnTransfer.setOnClickListener { initiateTransfer() }
|
binding.btnTransfer.setOnClickListener { initiateTransfer() }
|
||||||
|
|
||||||
// Pre-select contact if navigated from contacts page
|
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
||||||
|
|
||||||
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
accountNumber = account,
|
accountNumber = account,
|
||||||
@@ -141,21 +184,103 @@ class TransferFragment : Fragment() {
|
|||||||
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLookupLoading() {
|
||||||
|
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||||
|
setStyle(CircularProgressDrawable.DEFAULT)
|
||||||
|
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
binding.tilTo.endIconDrawable = spinner
|
||||||
|
binding.tilTo.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLookupLoading() {
|
||||||
|
binding.tilTo.isEnabled = true
|
||||||
|
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupFromDropdown() {
|
private fun setupFromDropdown() {
|
||||||
|
binding.btnClearFromInfo.setOnClickListener {
|
||||||
|
selectedAccount = null
|
||||||
|
binding.tilAmount.prefixText = null
|
||||||
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
|
binding.actvFrom.setText("", false)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
val adapter = AccountDropdownAdapter(requireContext(), accounts)
|
val adapter = AccountDropdownAdapter(requireContext(), accounts)
|
||||||
binding.actvFrom.setAdapter(adapter)
|
binding.actvFrom.setAdapter(adapter)
|
||||||
|
|
||||||
// No default selection — user must explicitly pick a source account
|
|
||||||
|
|
||||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||||
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
||||||
selectedAccount = picked
|
selectedAccount = picked
|
||||||
binding.actvFrom.setText(picked.toDisplayString(), false)
|
|
||||||
updateAmountPrefix(picked)
|
updateAmountPrefix(picked)
|
||||||
|
showFromCard(picked)
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||||
|
if (fromNumber != null && selectedAccount == null) {
|
||||||
|
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
||||||
|
if (match != null) {
|
||||||
|
selectedAccount = match
|
||||||
|
updateAmountPrefix(match)
|
||||||
|
showFromCard(match)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFromCard(account: MibAccount) {
|
||||||
|
val colorHex = when (account.bank) {
|
||||||
|
"BML" -> "#0066A1"
|
||||||
|
"FAHIPAY" -> "#15BEA7"
|
||||||
|
else -> "#FE860E"
|
||||||
|
}
|
||||||
|
val bankLabel = when (account.bank) {
|
||||||
|
"BML" -> "BML"
|
||||||
|
"FAHIPAY" -> "FP"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val typeLabel = when {
|
||||||
|
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
||||||
|
account.profileType == "BML_CREDIT" -> "Credit Card"
|
||||||
|
account.accountTypeName.isNotBlank() -> account.accountTypeName
|
||||||
|
else -> account.profileType
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvFromAccountName.text = account.accountBriefName
|
||||||
|
binding.tvFromAccountNumber.text = account.accountNumber
|
||||||
|
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, "${account.currencyName} ${account.availableBalance}").joinToString(" · ")
|
||||||
|
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex))
|
||||||
|
binding.tilFrom.visibility = View.GONE
|
||||||
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
if (account.bank != "BML" && account.profileImageHash != null) {
|
||||||
|
loadFromPhoto(account.profileImageHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadFromPhoto(hash: String) {
|
||||||
|
val sess = session ?: return
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val base64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@launch
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (_binding != null) binding.ivFromPhoto.setImageBitmap(bitmap)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,11 +294,14 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnClearToInfo.setOnClickListener {
|
binding.btnClearToInfo.setOnClickListener {
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
|
selectedFahipayService = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
|
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.etTo.addTextChangedListener {
|
binding.etTo.addTextChangedListener {
|
||||||
@@ -185,12 +313,17 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupAccount() {
|
private fun lookupAccount() {
|
||||||
val accountNumber = binding.etTo.text?.toString()?.trim() ?: ""
|
if (selectedAccount == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
|
||||||
if (accountNumber.isBlank()) {
|
if (accountNumber.isBlank()) {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -210,22 +343,37 @@ class TransferFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fahipay source: only phone numbers are supported
|
||||||
|
if (selectedAccount?.bank == "FAHIPAY") {
|
||||||
|
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
|
||||||
|
lookupFahipayTarget(accountNumber)
|
||||||
|
} else {
|
||||||
|
binding.tilTo.error = getString(R.string.transfer_fahipay_phone_only)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val mibSess = session
|
val mibSess = session
|
||||||
val bmlSess = bmlSession
|
val bmlSess = bmlSessionFor(selectedAccount)
|
||||||
if (mibSess == null && bmlSess == null) {
|
if (mibSess == null && bmlSess == null) {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
|
val isBmlSource = selectedAccount?.bank == "BML"
|
||||||
|
|
||||||
binding.tilTo.isEnabled = false
|
startLookupLoading()
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
var errorMsg: String? = null
|
var errorMsg: String? = null
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
if (isBmlSource && bmlSess != null) {
|
if (isBmlSource && bmlSess != null) {
|
||||||
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null }
|
val inputType = AccountInputParser.detect(accountNumber)
|
||||||
|
val bmlFlow = BmlLoginFlow()
|
||||||
|
val bmlResult = try {
|
||||||
|
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) bmlFlow.verifyMibAccount(bmlSess, accountNumber)
|
||||||
|
else bmlFlow.validateAccount(bmlSess, accountNumber)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
if (bmlResult != null) {
|
if (bmlResult != null) {
|
||||||
val bankId = when (bmlResult.trnType) {
|
val bankId = when (bmlResult.trnType) {
|
||||||
"IAT" -> "MALBMVMV"
|
"IAT" -> "MALBMVMV"
|
||||||
@@ -258,7 +406,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.tilTo.isEnabled = true
|
stopLookupLoading()
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
val accounts = viewModel.accounts.value ?: emptyList()
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
|
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
|
||||||
@@ -278,6 +426,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
updateTransferButton()
|
||||||
saveToRecents(info)
|
saveToRecents(info)
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -292,6 +441,99 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun lookupFahipayTarget(number: String) {
|
||||||
|
startLookupLoading()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
data class LookupResult(
|
||||||
|
val dhiraagu: DhiraaguClient.Result,
|
||||||
|
val ooredoo: OoredooClient.CustType
|
||||||
|
)
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
if (number.startsWith("7")) {
|
||||||
|
// Dhiraagu first, fall back to Ooredoo
|
||||||
|
val d = try { DhiraaguClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||||
|
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED)
|
||||||
|
try { OoredooClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||||
|
else OoredooClient.CustType.UNSUPPORTED
|
||||||
|
LookupResult(d, o)
|
||||||
|
} else {
|
||||||
|
// Ooredoo first, fall back to Dhiraagu
|
||||||
|
val o = try { OoredooClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||||
|
val d = if (o == OoredooClient.CustType.UNSUPPORTED)
|
||||||
|
try { DhiraaguClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||||
|
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||||
|
LookupResult(d, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stopLookupLoading()
|
||||||
|
|
||||||
|
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// Collect all applicable services
|
||||||
|
val services = buildList {
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add("DHIRAAGU_RELOAD")
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add("DHIRAAGU_BILL")
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add("RAASTAS")
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add("OOREDOO_BILL")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (services.isEmpty()) return@launch
|
||||||
|
|
||||||
|
// Only one option — auto-select, no chip UI needed
|
||||||
|
if (services.size == 1) {
|
||||||
|
selectFahipayService(services[0], number, dhiraaguName)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple options (Ooredoo HYBRID) — show chips
|
||||||
|
binding.chipDhiraaguReload.visibility = if ("DHIRAAGU_RELOAD" in services) View.VISIBLE else View.GONE
|
||||||
|
binding.chipDhiraaguBill.visibility = if ("DHIRAAGU_BILL" in services) View.VISIBLE else View.GONE
|
||||||
|
binding.chipRaastas.visibility = if ("RAASTAS" in services) View.VISIBLE else View.GONE
|
||||||
|
binding.chipOoredooBill.visibility = if ("OOREDOO_BILL" in services) View.VISIBLE else View.GONE
|
||||||
|
binding.layoutServiceSelector.visibility = View.VISIBLE
|
||||||
|
binding.chipGroupService.clearCheck()
|
||||||
|
|
||||||
|
binding.chipDhiraaguReload.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) { selectFahipayService("DHIRAAGU_RELOAD", number, dhiraaguName); binding.layoutServiceSelector.visibility = View.INVISIBLE }
|
||||||
|
}
|
||||||
|
binding.chipDhiraaguBill.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) { selectFahipayService("DHIRAAGU_BILL", number, dhiraaguName); binding.layoutServiceSelector.visibility = View.INVISIBLE }
|
||||||
|
}
|
||||||
|
binding.chipRaastas.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) { selectFahipayService("RAASTAS", number); binding.layoutServiceSelector.visibility = View.INVISIBLE }
|
||||||
|
}
|
||||||
|
binding.chipOoredooBill.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) { selectFahipayService("OOREDOO_BILL", number); binding.layoutServiceSelector.visibility = View.INVISIBLE }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectFahipayService(service: String, number: String, ownerName: String? = null) {
|
||||||
|
selectedFahipayService = service
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
val displayName = ownerName
|
||||||
|
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||||
|
?: number
|
||||||
|
val serviceLabel = when (service) {
|
||||||
|
"RAASTAS" -> "Raastas"
|
||||||
|
"OOREDOO_BILL" -> "Ooredoo Bill Pay"
|
||||||
|
"DHIRAAGU_RELOAD" -> "Dhiraagu Reload"
|
||||||
|
"DHIRAAGU_BILL" -> "Dhiraagu Bill Pay"
|
||||||
|
else -> service
|
||||||
|
}
|
||||||
|
prefillToDirectly(
|
||||||
|
accountNumber = number,
|
||||||
|
displayName = displayName,
|
||||||
|
subtitle = "$serviceLabel · $number",
|
||||||
|
colorHex = "#FF6B00",
|
||||||
|
imageHash = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun prefillToDirectly(
|
private fun prefillToDirectly(
|
||||||
accountNumber: String,
|
accountNumber: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
@@ -311,6 +553,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
updateTransferButton()
|
||||||
|
|
||||||
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
@@ -358,9 +601,9 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilAmount.error = null
|
binding.tilAmount.error = null
|
||||||
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
val isSrcBml = src.profileType.startsWith("BML")
|
val isSrcBml = src.bank == "BML"
|
||||||
val isSrcCard = src.profileType == "BML_PREPAID"
|
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
|
||||||
val isDestMib = resolvedAccountNumber.matches(Regex("^9\\d{16}$"))
|
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||||
val currency = src.currencyName.ifBlank { "MVR" }
|
val currency = src.currencyName.ifBlank { "MVR" }
|
||||||
val allAccounts = viewModel.accounts.value ?: emptyList()
|
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||||
val allContacts = viewModel.contacts.value ?: emptyList()
|
val allContacts = viewModel.contacts.value ?: emptyList()
|
||||||
@@ -369,10 +612,10 @@ class TransferFragment : Fragment() {
|
|||||||
if (isSrcBml && isDestMib && currency == "USD") {
|
if (isSrcBml && isDestMib && currency == "USD") {
|
||||||
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
|
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
|
||||||
if (!hasBmlContact) {
|
if (!hasBmlContact) {
|
||||||
AlertDialog.Builder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(R.string.transfer_bml_contact_required_title)
|
.setTitle(R.string.transfer_bml_contact_required_title)
|
||||||
.setMessage(R.string.transfer_bml_contact_required_msg)
|
.setMessage(R.string.transfer_bml_contact_required_msg)
|
||||||
.setPositiveButton("OK", null)
|
.setPositiveButton(R.string.close, null)
|
||||||
.show()
|
.show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -381,32 +624,110 @@ class TransferFragment : Fragment() {
|
|||||||
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
|
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
|
||||||
val bankNameCapture = resolvedBankName
|
val bankNameCapture = resolvedBankName
|
||||||
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
||||||
AlertDialog.Builder(requireContext())
|
|
||||||
.setTitle(R.string.transfer)
|
val destCurrency = allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||||
.setMessage("Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}")
|
?.currencyName?.ifBlank { "MVR" }
|
||||||
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
|
?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber }
|
||||||
binding.btnTransfer.isEnabled = false
|
?.transferCyDesc?.ifBlank { "MVR" }
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
?: if (isDestMib) "MVR" else "MVR"
|
||||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
|
||||||
if (!isSrcBml) {
|
val isSrcCredit = src.profileType == "BML_CREDIT"
|
||||||
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
|
|
||||||
} else {
|
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
||||||
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
|
||||||
}
|
val doTransfer: () -> Unit = {
|
||||||
}
|
binding.btnTransfer.isEnabled = false
|
||||||
binding.btnTransfer.isEnabled = true
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
if (ok && receipt != null) {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
clearForm()
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
val activity = requireActivity() as HomeActivity
|
if (!isSrcBml) {
|
||||||
activity.refreshBalances(src)
|
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
} else {
|
||||||
} else if (!ok) {
|
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
|
clearForm()
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.refreshBalances(src)
|
||||||
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
|
} else if (!ok) {
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(R.string.transfer)
|
||||||
|
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
.show()
|
|
||||||
|
if (isUsdToMvr || isSrcCredit) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply { text = mainMsg })
|
||||||
|
if (isUsdToMvr) {
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
|
||||||
|
setTextColor(Color.RED)
|
||||||
|
textSize = 16f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (isSrcCredit) {
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
|
||||||
|
setTextColor(Color.RED)
|
||||||
|
textSize = 16f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
dialogBuilder.setView(container)
|
||||||
|
} else {
|
||||||
|
dialogBuilder.setMessage(mainMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = dialogBuilder.show()
|
||||||
|
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
val canAuth = BiometricManager.from(requireContext())
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
if (biometricTransferConfirm && canAuth) {
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
dialog.dismiss()
|
||||||
|
doTransfer()
|
||||||
|
}
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||||
|
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||||
|
})
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(getString(R.string.biometric_transfer_title))
|
||||||
|
.setSubtitle("$currency $amountStr → $destDisplay")
|
||||||
|
.setNegativeButtonText(getString(android.R.string.cancel))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun doMibTransfer(
|
private fun doMibTransfer(
|
||||||
@@ -420,17 +741,19 @@ class TransferFragment : Fragment() {
|
|||||||
): Triple<Boolean, String, TransferReceiptData?> {
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
// Switch to the profile that owns the source account
|
// Switch to the profile that owns the source account
|
||||||
if (src.profileId.isNotBlank()) {
|
if (src.profileId.isNotBlank()) {
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId }
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
|
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
}
|
}
|
||||||
val otp = CredentialStore(requireContext()).loadMibCredentials()?.otpSeed
|
val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed
|
||||||
?.let { Totp.generate(it) }
|
?.let { Totp.generate(it) }
|
||||||
?: return Triple(false, "OTP unavailable", null)
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||||
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
||||||
val isDestMib = destAccount.matches(Regex("^9\\d{16}$"))
|
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||||
val bankNo = if (isDestMib) 2 else 3
|
val bankNo = if (isDestMib) 2 else 3
|
||||||
val toBank = when {
|
val toBank = when {
|
||||||
isDestMib -> "MIB"
|
isDestMib -> "MIB"
|
||||||
@@ -454,7 +777,7 @@ class TransferFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
isMib = true,
|
bank = "MIB",
|
||||||
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||||
currency = currency,
|
currency = currency,
|
||||||
fromLabel = src.accountBriefName,
|
fromLabel = src.accountBriefName,
|
||||||
@@ -489,8 +812,9 @@ class TransferFragment : Fragment() {
|
|||||||
allAccounts: List<MibAccount>,
|
allAccounts: List<MibAccount>,
|
||||||
allContacts: List<MibBeneficiary>
|
allContacts: List<MibBeneficiary>
|
||||||
): Triple<Boolean, String, TransferReceiptData?> {
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
val sess = bmlSession ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
val otp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
|
val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
||||||
|
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
?.let { Totp.generate(it) }
|
?.let { Totp.generate(it) }
|
||||||
?: return Triple(false, "OTP unavailable", null)
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
val debitAccount = src.internalId.ifBlank {
|
val debitAccount = src.internalId.ifBlank {
|
||||||
@@ -498,7 +822,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine type + credit account
|
// Determine type + credit account
|
||||||
val isDestMyCard = allAccounts.any { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount }
|
val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
|
||||||
val (transferType, creditAccount, bank) = when {
|
val (transferType, creditAccount, bank) = when {
|
||||||
isSrcCard -> {
|
isSrcCard -> {
|
||||||
// CAD: card → own BML account
|
// CAD: card → own BML account
|
||||||
@@ -507,7 +831,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
isDestMyCard -> {
|
isDestMyCard -> {
|
||||||
// CPA: BML CASA → own card top-up
|
// CPA: BML CASA → own card top-up
|
||||||
val card = allAccounts.first { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount }
|
val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
|
||||||
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
|
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
|
||||||
}
|
}
|
||||||
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||||
@@ -530,14 +854,14 @@ class TransferFragment : Fragment() {
|
|||||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||||
|
|
||||||
// Step 2: confirm with fresh OTP
|
// Step 2: confirm with fresh OTP
|
||||||
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
|
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
?.let { Totp.generate(it) } ?: otp
|
?.let { Totp.generate(it) } ?: otp
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
isMib = false,
|
bank = "BML",
|
||||||
amount = "%.2f".format(amount),
|
amount = "%.2f".format(amount),
|
||||||
currency = currency,
|
currency = currency,
|
||||||
fromLabel = src.accountBriefName,
|
fromLabel = src.accountBriefName,
|
||||||
@@ -548,7 +872,8 @@ class TransferFragment : Fragment() {
|
|||||||
remarks = remarks,
|
remarks = remarks,
|
||||||
bmlFromName = src.accountBriefName,
|
bmlFromName = src.accountBriefName,
|
||||||
bmlReference = result.reference,
|
bmlReference = result.reference,
|
||||||
bmlTimestamp = result.timestamp
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
)
|
)
|
||||||
val time = result.timestamp.take(19).replace("T", " ")
|
val time = result.timestamp.take(19).replace("T", " ")
|
||||||
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
||||||
@@ -560,16 +885,25 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateTransferButton() {
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
|
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearForm() {
|
private fun clearForm() {
|
||||||
selectedAccount = null
|
selectedAccount = null
|
||||||
binding.actvFrom.setText("", false)
|
binding.actvFrom.setText("", false)
|
||||||
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
binding.tilAmount.prefixText = null
|
binding.tilAmount.prefixText = null
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
binding.etRemarks.setText("")
|
binding.etRemarks.setText("")
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedBankName = ""
|
resolvedBankName = ""
|
||||||
|
selectedFahipayService = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
|
binding.chipGroupService.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
@@ -586,7 +920,7 @@ class TransferFragment : Fragment() {
|
|||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = if (isProfile) {
|
val base64 = if (isProfile) {
|
||||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||||
} else {
|
} else {
|
||||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
@@ -675,8 +1009,8 @@ class TransferFragment : Fragment() {
|
|||||||
) : BaseAdapter(), Filterable {
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
private val items: List<Any> = buildList {
|
private val items: List<Any> = buildList {
|
||||||
val regular = accounts.filter { it.profileType != "BML_PREPAID" }
|
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" }
|
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||||
addAll(regular)
|
addAll(regular)
|
||||||
if (cards.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
add(getString(R.string.cards))
|
add(getString(R.string.cards))
|
||||||
@@ -685,7 +1019,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
|
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
|
||||||
?.takeUnless { it.profileType == "BML_PREPAID" && !it.statusDesc.equals("Active", ignoreCase = true) }
|
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
|
|
||||||
override fun getCount() = items.size
|
override fun getCount() = items.size
|
||||||
override fun getItem(position: Int) = items[position]
|
override fun getItem(position: Int) = items[position]
|
||||||
@@ -716,10 +1050,12 @@ class TransferFragment : Fragment() {
|
|||||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
.also { it.root.tag = it }
|
.also { it.root.tag = it }
|
||||||
}
|
}
|
||||||
val inactive = acc.profileType == "BML_PREPAID" && !acc.statusDesc.equals("Active", ignoreCase = true)
|
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
||||||
b.tvDropdownAccountName.text = acc.accountBriefName
|
val isBmlAccount = acc.bank == "BML"
|
||||||
|
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
||||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: ""
|
||||||
b.root.alpha = if (inactive) 0.4f else 1f
|
b.root.alpha = if (inactive) 0.4f else 1f
|
||||||
b.root
|
b.root
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
@@ -8,6 +10,8 @@ import android.util.Base64
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -22,6 +26,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
@@ -29,6 +34,7 @@ import sh.sar.basedbank.api.mib.Transaction
|
|||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.MerchantIconCache
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -52,12 +58,15 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
var mibTotalCount: Int = -1,
|
var mibTotalCount: Int = -1,
|
||||||
var bmlNextPage: Int = 1,
|
var bmlNextPage: Int = 1,
|
||||||
var bmlTotalPages: Int = -1,
|
var bmlTotalPages: Int = -1,
|
||||||
var cardMonthOffset: Int = 0
|
var cardMonthOffset: Int = 0,
|
||||||
|
var fahipayNextStart: Int = 0,
|
||||||
|
var fahipayTotal: Int = -1
|
||||||
) {
|
) {
|
||||||
fun hasMore(): Boolean = when {
|
fun hasMore(): Boolean = when {
|
||||||
account.profileType == "BML_PREPAID" -> cardMonthOffset < 2
|
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
||||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
|
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +75,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
private var firstBatchDone = false
|
private var firstBatchDone = false
|
||||||
private val pageSize = 20
|
private val pageSize = 20
|
||||||
private val pendingImageNames = mutableSetOf<String>()
|
private val pendingImageNames = mutableSetOf<String>()
|
||||||
|
private val pendingIconUrls = mutableSetOf<String>()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
|
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
|
||||||
@@ -75,9 +85,21 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
adapter = TransactionAdapter()
|
adapter = TransactionAdapter()
|
||||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
|
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||||
if (dy <= 0 || isLoading) return
|
if (dy <= 0 || isLoading) return
|
||||||
@@ -133,21 +155,19 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val mibSession = app.mibSession
|
|
||||||
val bmlSession = app.bmlSession
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val newTransactions = withContext(Dispatchers.IO) {
|
val newTransactions = withContext(Dispatchers.IO) {
|
||||||
val results = mutableListOf<Transaction>()
|
val results = mutableListOf<Transaction>()
|
||||||
|
|
||||||
// BML accounts: fetch in parallel
|
// BML accounts: fetch in parallel
|
||||||
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
|
val bmlStates = activeStates.filter { it.account.bank == "BML" }
|
||||||
results.addAll(bmlStates.map { state ->
|
results.addAll(bmlStates.map { state ->
|
||||||
async {
|
async {
|
||||||
try {
|
try {
|
||||||
when {
|
when {
|
||||||
state.account.profileType == "BML_PREPAID" -> {
|
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> {
|
||||||
val session = bmlSession ?: return@async emptyList()
|
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||||
val cal = Calendar.getInstance()
|
val cal = Calendar.getInstance()
|
||||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||||
@@ -161,7 +181,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val session = bmlSession ?: return@async emptyList()
|
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||||
session = session,
|
session = session,
|
||||||
accountId = state.account.internalId,
|
accountId = state.account.internalId,
|
||||||
@@ -178,26 +198,48 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.awaitAll().flatten())
|
||||||
|
|
||||||
|
// Fahipay accounts
|
||||||
|
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||||
|
for (state in fahipayStates) {
|
||||||
|
val session = app.fahipaySessionFor(state.account) ?: continue
|
||||||
|
try {
|
||||||
|
val flow = FahipayLoginFlow()
|
||||||
|
flow.setSessionCookie(session.sessionCookie)
|
||||||
|
val (list, total) = flow.fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountDisplayName = state.account.accountBriefName,
|
||||||
|
accountNumber = state.account.accountNumber,
|
||||||
|
start = state.fahipayNextStart
|
||||||
|
)
|
||||||
|
if (total > 0) state.fahipayTotal = total
|
||||||
|
state.fahipayNextStart += list.size
|
||||||
|
results.addAll(list)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||||
val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") }
|
val mibStates = activeStates.filter { it.account.bank == "MIB" }
|
||||||
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
|
for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) {
|
||||||
val session = mibSession ?: break
|
val session = app.mibSessions[loginId] ?: continue
|
||||||
app.mibMutex.withLock {
|
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
|
app.mibMutex.withLock {
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
for (state in states) {
|
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||||
try {
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
for (state in states) {
|
||||||
session = session,
|
try {
|
||||||
accountNo = state.account.accountNumber,
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
accountDisplayName = state.account.accountBriefName,
|
session = session,
|
||||||
start = state.mibNextStart,
|
accountNo = state.account.accountNumber,
|
||||||
pageSize = pageSize
|
accountDisplayName = state.account.accountBriefName,
|
||||||
)
|
start = state.mibNextStart,
|
||||||
if (total > 0) state.mibTotalCount = total
|
pageSize = pageSize
|
||||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
)
|
||||||
results.addAll(list)
|
if (total > 0) state.mibTotalCount = total
|
||||||
} catch (_: Exception) {}
|
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
|
results.addAll(list)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +293,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.mibSession ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
@@ -265,6 +307,28 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadMerchantIcon(url: String) {
|
||||||
|
if (!pendingIconUrls.add(url)) return
|
||||||
|
val cached = MerchantIconCache.load(requireContext(), url)
|
||||||
|
if (cached != null) {
|
||||||
|
binding.recyclerView.post { adapter.updateIconUrl(url, cached) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||||
|
val bytes = response.body?.bytes() ?: return@launch
|
||||||
|
response.close()
|
||||||
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
|
MerchantIconCache.save(requireContext(), url, bitmap)
|
||||||
|
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
pendingIconUrls.remove(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
data class TransferReceiptData(
|
data class TransferReceiptData(
|
||||||
val isMib: Boolean,
|
val bank: String, // "MIB", "BML", etc.
|
||||||
val amount: String,
|
val amount: String,
|
||||||
val currency: String,
|
val currency: String,
|
||||||
val fromLabel: String,
|
val fromLabel: String,
|
||||||
@@ -18,4 +18,5 @@ data class TransferReceiptData(
|
|||||||
val bmlFromName: String = "",
|
val bmlFromName: String = "",
|
||||||
val bmlReference: String = "",
|
val bmlReference: String = "",
|
||||||
val bmlTimestamp: String = "",
|
val bmlTimestamp: String = "",
|
||||||
|
val bmlMessage: String = "",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private val receiptCard get() = _receiptCard!!
|
private val receiptCard get() = _receiptCard!!
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARG_IS_MIB = "is_mib"
|
private const val ARG_BANK = "bank"
|
||||||
private const val ARG_AMOUNT = "amount"
|
private const val ARG_AMOUNT = "amount"
|
||||||
private const val ARG_CURRENCY = "currency"
|
private const val ARG_CURRENCY = "currency"
|
||||||
private const val ARG_FROM_LABEL = "from_label"
|
private const val ARG_FROM_LABEL = "from_label"
|
||||||
@@ -61,6 +65,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
||||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||||
|
private const val ARG_BML_MESSAGE = "bml_message"
|
||||||
|
|
||||||
// Holds the already-rendered to-avatar bitmap from TransferFragment
|
// Holds the already-rendered to-avatar bitmap from TransferFragment
|
||||||
var pendingToAvatarBitmap: Bitmap? = null
|
var pendingToAvatarBitmap: Bitmap? = null
|
||||||
@@ -68,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
|
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
|
||||||
pendingToAvatarBitmap = toAvatarBitmap
|
pendingToAvatarBitmap = toAvatarBitmap
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putBoolean(ARG_IS_MIB, data.isMib)
|
putString(ARG_BANK, data.bank)
|
||||||
putString(ARG_AMOUNT, data.amount)
|
putString(ARG_AMOUNT, data.amount)
|
||||||
putString(ARG_CURRENCY, data.currency)
|
putString(ARG_CURRENCY, data.currency)
|
||||||
putString(ARG_FROM_LABEL, data.fromLabel)
|
putString(ARG_FROM_LABEL, data.fromLabel)
|
||||||
@@ -83,13 +88,14 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
||||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||||
|
putString(ARG_BML_MESSAGE, data.bmlMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
return if (isMib) {
|
return if (bank == "MIB") {
|
||||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||||
bindMib(binding)
|
bindMib(binding)
|
||||||
_receiptCard = binding.receiptCard
|
_receiptCard = binding.receiptCard
|
||||||
@@ -103,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||||
parentFragmentManager.popBackStack()
|
parentFragmentManager.popBackStack()
|
||||||
}
|
}
|
||||||
@@ -147,16 +155,22 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
|
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
|
||||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "").ifBlank { "-" }
|
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
||||||
|
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
||||||
|
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.mibSession ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = if (isProfile) {
|
val base64 = if (isProfile) {
|
||||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||||
} else {
|
} else {
|
||||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
@@ -181,7 +195,9 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
|
|
||||||
binding.tvAmountValue.text = formattedAmount
|
binding.tvAmountValue.text = formattedAmount
|
||||||
binding.tvAmountCurrency.text = currency
|
binding.tvAmountCurrency.text = currency
|
||||||
binding.tvMessageRow.text = "Thank you. Transfer transaction is successful."
|
val apiMessage = args.getString(ARG_BML_MESSAGE, "")
|
||||||
|
binding.tvMessage.text = apiMessage
|
||||||
|
binding.tvMessageRow.text = apiMessage
|
||||||
binding.tvReference.text = args.getString(ARG_BML_REFERENCE, "")
|
binding.tvReference.text = args.getString(ARG_BML_REFERENCE, "")
|
||||||
binding.tvTransactionDate.text = formatBmlTimestamp(args.getString(ARG_BML_TIMESTAMP, ""))
|
binding.tvTransactionDate.text = formatBmlTimestamp(args.getString(ARG_BML_TIMESTAMP, ""))
|
||||||
binding.tvFrom.text = args.getString(ARG_BML_FROM_NAME, "").ifBlank {
|
binding.tvFrom.text = args.getString(ARG_BML_FROM_NAME, "").ifBlank {
|
||||||
@@ -197,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
binding.remarksDivider.visibility = View.VISIBLE
|
binding.remarksDivider.visibility = View.VISIBLE
|
||||||
binding.remarksRow.visibility = View.VISIBLE
|
binding.remarksRow.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
|
||||||
|
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
|
||||||
|
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
|
||||||
|
binding.tvAmountCurrency, binding.tvRemarks
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||||
@@ -306,9 +329,63 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showFullScreenReceipt() {
|
||||||
|
captureReceiptBitmap { bitmap ->
|
||||||
|
if (bitmap == null) return@captureReceiptBitmap
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
val iv = android.widget.ImageView(ctx).apply {
|
||||||
|
setImageBitmap(bitmap)
|
||||||
|
scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
}
|
||||||
|
iv.setOnClickListener { dialog.dismiss() }
|
||||||
|
dialog.setContentView(iv)
|
||||||
|
val actWin = requireActivity().window
|
||||||
|
val prevColor = actWin.statusBarColor
|
||||||
|
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
|
||||||
|
actWin.statusBarColor = Color.BLACK
|
||||||
|
insetsCtrl.isAppearanceLightStatusBars = false
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
actWin.statusBarColor = prevColor
|
||||||
|
val isLight = (resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
dialog.window?.let { win ->
|
||||||
|
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat(win, iv).apply {
|
||||||
|
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||||
|
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
|
for (tv in views) {
|
||||||
|
tv.setOnLongClickListener {
|
||||||
|
val text = tv.text?.toString()?.trim() ?: return@setOnLongClickListener false
|
||||||
|
if (text.isBlank()) return@setOnLongClickListener false
|
||||||
|
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
cm.setPrimaryClip(ClipData.newPlainText("receipt", text))
|
||||||
|
Toast.makeText(requireContext(), "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = "Receipt"
|
requireActivity().title = "Receipt"
|
||||||
|
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class BankSelectionFragment : Fragment() {
|
|||||||
val args = android.os.Bundle().apply { putString("bankType", "BML") }
|
val args = android.os.Bundle().apply { putString("bankType", "BML") }
|
||||||
findNavController().navigate(R.id.action_bankSelection_to_credentials_bml, args)
|
findNavController().navigate(R.id.action_bankSelection_to_credentials_bml, args)
|
||||||
}
|
}
|
||||||
|
binding.cardFahipay.setOnClickListener {
|
||||||
|
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
|
||||||
|
findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|||||||
@@ -18,12 +18,21 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
|
||||||
class CredentialsFragment : Fragment() {
|
class CredentialsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -40,29 +49,70 @@ class CredentialsFragment : Fragment() {
|
|||||||
private var _binding: FragmentCredentialsBinding? = null
|
private var _binding: FragmentCredentialsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
// Fahipay two-step state
|
||||||
|
private var fahipayFlow: FahipayLoginFlow? = null
|
||||||
|
private var fahipayAwaitingTotp = false
|
||||||
|
|
||||||
|
// BML multi-profile state
|
||||||
|
private var bmlFlow: BmlLoginFlow? = null
|
||||||
|
private var bmlLoginId: String = ""
|
||||||
|
private var bmlAccumulatedAccounts = mutableListOf<MibAccount>()
|
||||||
|
private var bmlPendingBusinessProfiles = ArrayDeque<Pair<BmlProfile, List<BmlOtpChannel>>>()
|
||||||
|
private var bmlCurrentBusinessProfile: BmlProfile? = null
|
||||||
|
private var bmlSelectedChannel: String? = null
|
||||||
|
private var bmlAwaitingBusinessOtp = false
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
if (bankType == "BML") {
|
when (bankType) {
|
||||||
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector)
|
"BML" -> {
|
||||||
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
|
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_long)
|
||||||
|
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
|
||||||
|
}
|
||||||
|
"FAHIPAY" -> {
|
||||||
|
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
||||||
|
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
||||||
|
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
||||||
|
binding.tilOtpSeed.visibility = android.view.View.GONE
|
||||||
|
binding.etOtpSeed.isEnabled = false
|
||||||
|
binding.etOtpSeed.isFocusable = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||||
|
|
||||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
val loginFieldWatcher = object : TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() }
|
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
}
|
||||||
|
binding.etUsername.addTextChangedListener(loginFieldWatcher)
|
||||||
|
binding.etPassword.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) { updateLoginButtonState(); updateOtpDisplay() }
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (bankType != "FAHIPAY") {
|
||||||
|
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
updateOtpDisplay()
|
||||||
|
updateLoginButtonState()
|
||||||
|
}
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
otpHandler.post(otpRunnable)
|
if (bankType != "FAHIPAY") otpHandler.post(otpRunnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@@ -70,10 +120,35 @@ class CredentialsFragment : Fragment() {
|
|||||||
otpHandler.removeCallbacks(otpRunnable)
|
otpHandler.removeCallbacks(otpRunnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveOtpSeed(input: String): String {
|
||||||
|
val secret = if (input.startsWith("otpauth://totp/"))
|
||||||
|
android.net.Uri.parse(input).getQueryParameter("secret") ?: input
|
||||||
|
else
|
||||||
|
input
|
||||||
|
return secret.replace("\\s".toRegex(), "").replace("-", "").uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLoginButtonState() {
|
||||||
|
val username = binding.etUsername.text.toString().trim()
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
|
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||||
|
binding.btnLogin.isEnabled = when (bankType) {
|
||||||
|
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||||
|
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateOtpDisplay() {
|
private fun updateOtpDisplay() {
|
||||||
val seed = binding.etOtpSeed.text.toString().trim()
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
|
val seed = resolveOtpSeed(otpSeedRaw)
|
||||||
if (seed.isEmpty()) {
|
if (seed.isEmpty()) {
|
||||||
binding.cardOtp.visibility = View.GONE
|
binding.cardOtp.visibility = View.INVISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||||
|
binding.cardOtp.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -86,19 +161,23 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.otpTimer.progress = remaining
|
binding.otpTimer.progress = remaining
|
||||||
binding.cardOtp.visibility = View.VISIBLE
|
binding.cardOtp.visibility = View.VISIBLE
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.cardOtp.visibility = View.GONE
|
binding.cardOtp.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attemptLogin() {
|
private fun attemptLogin() {
|
||||||
if (bankType == "BML") {
|
when (bankType) {
|
||||||
attemptBmlLogin()
|
"BML" -> {
|
||||||
return
|
if (bmlAwaitingBusinessOtp) submitBmlBusinessOtp()
|
||||||
|
else attemptBmlLogin()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
val username = binding.etUsername.text.toString().trim()
|
val username = binding.etUsername.text.toString().trim()
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||||
|
|
||||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||||
binding.tvError.text = "Please fill in all fields"
|
binding.tvError.text = "Please fill in all fields"
|
||||||
@@ -111,8 +190,8 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
val passwordHash = MibLoginFlow.hashPassword(password)
|
val passwordHash = MibLoginFlow.hashPassword(password)
|
||||||
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
|
val loginId = username
|
||||||
val flow = MibLoginFlow(prefs)
|
val flow = MibLoginFlow(CredentialStore(requireContext()))
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -120,11 +199,12 @@ class CredentialsFragment : Fragment() {
|
|||||||
flow.login(username, passwordHash, otpSeed)
|
flow.login(username, passwordHash, otpSeed)
|
||||||
}
|
}
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
store.saveMibCredentials(username, passwordHash, otpSeed)
|
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
flow.lastSession?.let { s ->
|
flow.lastSession?.let { s ->
|
||||||
val profile = flow.fetchPersonalProfile(s)
|
val profile = flow.fetchPersonalProfile(s)
|
||||||
if (profile != null) store.saveMibUserProfile(
|
if (profile != null) store.saveMibUserProfile(
|
||||||
|
loginId,
|
||||||
CredentialStore.MibUserProfile(
|
CredentialStore.MibUserProfile(
|
||||||
fullName = profile.fullName,
|
fullName = profile.fullName,
|
||||||
username = profile.username,
|
username = profile.username,
|
||||||
@@ -135,11 +215,15 @@ class CredentialsFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AccountCache.save(requireContext(), accounts)
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.accounts = accounts
|
// Merge with any existing MIB accounts from other logins
|
||||||
app.mibSession = flow.lastSession
|
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts
|
||||||
app.mibProfiles = flow.lastProfiles
|
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } + accounts
|
||||||
|
AccountCache.save(requireContext(), app.mibAccounts)
|
||||||
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
|
app.mibLoginFlows[loginId] = flow
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
@@ -156,7 +240,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
private fun attemptBmlLogin() {
|
private fun attemptBmlLogin() {
|
||||||
val username = binding.etUsername.text.toString().trim()
|
val username = binding.etUsername.text.toString().trim()
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||||
|
|
||||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||||
binding.tvError.text = "Please fill in all fields"
|
binding.tvError.text = "Please fill in all fields"
|
||||||
@@ -168,18 +252,186 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.progressBar.visibility = View.VISIBLE
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
val flow = BmlLoginFlow()
|
bmlLoginId = username
|
||||||
|
bmlAccumulatedAccounts.clear()
|
||||||
|
bmlPendingBusinessProfiles.clear()
|
||||||
|
bmlCurrentBusinessProfile = null
|
||||||
|
bmlSelectedChannel = null
|
||||||
|
bmlAwaitingBusinessOtp = false
|
||||||
|
|
||||||
|
val flow = BmlLoginFlow().also { bmlFlow = it }
|
||||||
|
val loginTag = "bml_$username"
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
|
flow.login(username, password, otpSeed)
|
||||||
|
}
|
||||||
|
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
|
||||||
|
|
||||||
|
// Activate each profile; personal profiles are immediate, business ones need OTP
|
||||||
|
for (profile in profiles) {
|
||||||
|
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
|
||||||
|
when (result) {
|
||||||
|
is BmlActivationResult.Success -> {
|
||||||
|
bmlAccumulatedAccounts += result.accounts
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.bmlSessions[profile.profileId] = result.session
|
||||||
|
}
|
||||||
|
is BmlActivationResult.NeedsBusinessOtp -> {
|
||||||
|
bmlPendingBusinessProfiles.addLast(Pair(profile, result.channels))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save credentials and profile list now (before business OTP prompts)
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
|
||||||
|
store.saveBmlProfiles(bmlLoginId, profiles)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.bmlProfilesMap[bmlLoginId] = profiles
|
||||||
|
app.bmlLoginFlows[bmlLoginId] = flow
|
||||||
|
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
|
||||||
|
if (bmlPendingBusinessProfiles.isNotEmpty()) {
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
} else {
|
||||||
|
finishBmlLogin()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "Login failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun processNextBmlBusinessProfile() {
|
||||||
|
val (profile, channels) = bmlPendingBusinessProfiles.removeFirstOrNull()
|
||||||
|
?: run { finishBmlLogin(); return }
|
||||||
|
|
||||||
|
bmlCurrentBusinessProfile = profile
|
||||||
|
|
||||||
|
// Show channel selection dialog
|
||||||
|
val selectedChannel = showBmlChannelDialog(profile.name, channels) ?: run {
|
||||||
|
// User skipped this profile — move on
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bmlSelectedChannel = selectedChannel
|
||||||
|
|
||||||
|
// Request OTP
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
bmlFlow!!.requestBusinessOtp(selectedChannel)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
binding.tvError.text = e.message ?: "Failed to send OTP"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show OTP input — disable credential fields (same pattern as Fahipay TOTP step)
|
||||||
|
bmlAwaitingBusinessOtp = true
|
||||||
|
binding.etUsername.isEnabled = false
|
||||||
|
binding.etPassword.isEnabled = false
|
||||||
|
binding.etOtpSeed.isEnabled = false
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.tilTotpCode.hint = getString(R.string.bml_business_otp_hint, profile.name)
|
||||||
|
binding.tilTotpCode.helperText = getString(R.string.bml_business_otp_sent, selectedChannel)
|
||||||
|
binding.tilTotpCode.visibility = View.VISIBLE
|
||||||
|
binding.etTotpCode.text?.clear()
|
||||||
|
binding.btnLogin.text = getString(R.string.verify)
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun submitBmlBusinessOtp() {
|
||||||
|
val code = binding.etTotpCode.text.toString().trim()
|
||||||
|
if (code.length != 6) {
|
||||||
|
binding.tvError.text = getString(R.string.fahipay_totp_hint)
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
|
val profile = bmlCurrentBusinessProfile ?: return
|
||||||
|
val channel = bmlSelectedChannel ?: return
|
||||||
|
val loginTag = "bml_$bmlLoginId"
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val (session, accounts) = withContext(Dispatchers.IO) {
|
val (session, accounts) = withContext(Dispatchers.IO) {
|
||||||
flow.login(username, password, otpSeed)
|
bmlFlow!!.submitBusinessOtp(channel, code, profile, loginTag)
|
||||||
}
|
}
|
||||||
|
bmlAccumulatedAccounts += accounts
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
store.saveBmlCredentials(username, password, otpSeed)
|
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
|
||||||
store.saveBmlSession(session.accessToken, session.deviceId)
|
val app = requireActivity().application as BasedBankApp
|
||||||
withContext(Dispatchers.IO) {
|
app.bmlSessions[profile.profileId] = session
|
||||||
val info = flow.fetchUserInfo(session)
|
|
||||||
if (info != null) store.saveBmlUserProfile(
|
bmlAwaitingBusinessOtp = false
|
||||||
|
binding.tilTotpCode.visibility = View.GONE
|
||||||
|
binding.btnLogin.text = getString(R.string.login)
|
||||||
|
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
|
||||||
|
if (bmlPendingBusinessProfiles.isNotEmpty()) {
|
||||||
|
processNextBmlBusinessProfile()
|
||||||
|
} else {
|
||||||
|
finishBmlLogin()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "OTP verification failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showBmlChannelDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val options = channels.map { "${it.description} (${it.masked})" }.toTypedArray()
|
||||||
|
val dialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(getString(R.string.bml_business_otp_title, profileName))
|
||||||
|
.setItems(options) { _, which ->
|
||||||
|
if (cont.isActive) cont.resume(channels[which].channel)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.bml_business_otp_skip) { _: android.content.DialogInterface, _: Int ->
|
||||||
|
if (cont.isActive) cont.resume(null as String?)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
dialog.setOnCancelListener { if (cont.isActive) cont.resume(null as String?) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun finishBmlLogin() {
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
val accounts = bmlAccumulatedAccounts.toList()
|
||||||
|
|
||||||
|
// Fetch user profile info from any active session
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val anySession = app.anyBmlSessionFor(bmlLoginId)
|
||||||
|
if (anySession != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val info = BmlLoginFlow().fetchUserInfo(anySession)
|
||||||
|
if (info != null) {
|
||||||
|
store.saveBmlUserProfile(
|
||||||
|
bmlLoginId,
|
||||||
CredentialStore.BmlUserProfile(
|
CredentialStore.BmlUserProfile(
|
||||||
fullName = info.fullName,
|
fullName = info.fullName,
|
||||||
email = info.email,
|
email = info.email,
|
||||||
@@ -189,16 +441,89 @@ class CredentialsFragment : Fragment() {
|
|||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
// Single-profile accounts used username as a temporary profileId.
|
||||||
|
// Replace it with the real BML customer ID so multi-login doesn't collide.
|
||||||
|
val customerId = info.customerId
|
||||||
|
if (customerId.isNotBlank()) {
|
||||||
|
val profiles = store.loadBmlProfiles(bmlLoginId)
|
||||||
|
val autoProfile = profiles.firstOrNull { it.autoActivated }
|
||||||
|
if (autoProfile != null && autoProfile.profileId != customerId) {
|
||||||
|
val oldId = autoProfile.profileId
|
||||||
|
// Re-key session in memory and storage
|
||||||
|
val session = app.bmlSessions.remove(oldId)
|
||||||
|
if (session != null) {
|
||||||
|
app.bmlSessions[customerId] = session
|
||||||
|
store.clearBmlProfileSession(oldId)
|
||||||
|
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
|
||||||
|
}
|
||||||
|
// Update stored profile list with the real ID
|
||||||
|
val updatedProfiles = profiles.map {
|
||||||
|
if (it.autoActivated) it.copy(profileId = customerId) else it
|
||||||
|
}
|
||||||
|
store.saveBmlProfiles(bmlLoginId, updatedProfiles)
|
||||||
|
app.bmlProfilesMap[bmlLoginId] = updatedProfiles
|
||||||
|
// Update accounts to use real profileId
|
||||||
|
bmlAccumulatedAccounts.replaceAll { acc ->
|
||||||
|
if (acc.profileId == oldId) acc.copy(profileId = customerId) else acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
|
||||||
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||||
|
|
||||||
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attemptFahipayLogin() {
|
||||||
|
if (fahipayAwaitingTotp) {
|
||||||
|
submitFahipayTotp()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val idCard = binding.etUsername.text.toString().trim()
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
|
||||||
|
if (idCard.isEmpty() || password.isEmpty()) {
|
||||||
|
binding.tvError.text = "Please fill in all fields"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
val deviceUuid = store.getOrCreateFahipayDeviceUuid()
|
||||||
|
val flow = FahipayLoginFlow().also { fahipayFlow = it }
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val step = withContext(Dispatchers.IO) {
|
||||||
|
flow.login(idCard, password, deviceUuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.twoFactorRequired) {
|
||||||
|
// Show TOTP input, disable ID + password fields
|
||||||
|
fahipayAwaitingTotp = true
|
||||||
|
binding.etUsername.isEnabled = false
|
||||||
|
binding.etPassword.isEnabled = false
|
||||||
|
binding.tilTotpCode.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.text = getString(R.string.fahipay_verify)
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
// No 2FA — finish login with the authId from the login response
|
||||||
|
val authId = step.authId ?: throw Exception("No authID received")
|
||||||
|
val cookieValue = flow.getSessionCookieValue() ?: ""
|
||||||
|
finishFahipayLogin(flow, FahipaySession(authId, cookieValue), idCard, password, store)
|
||||||
}
|
}
|
||||||
AccountCache.saveBml(requireContext(), accounts)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
app.bmlSession = session
|
|
||||||
app.bmlAccounts = accounts
|
|
||||||
// Merge with any existing MIB accounts already in app
|
|
||||||
app.accounts = app.accounts + accounts
|
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.tvError.text = e.message ?: "Login failed"
|
binding.tvError.text = e.message ?: "Login failed"
|
||||||
binding.tvError.visibility = View.VISIBLE
|
binding.tvError.visibility = View.VISIBLE
|
||||||
@@ -209,6 +534,85 @@ class CredentialsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun submitFahipayTotp() {
|
||||||
|
val code = binding.etTotpCode.text.toString().trim()
|
||||||
|
if (code.length != 6) {
|
||||||
|
binding.tvError.text = "Enter the 6-digit code from your authenticator app"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
|
val flow = fahipayFlow ?: run {
|
||||||
|
binding.tvError.text = "Session lost — please restart login"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
val deviceUuid = store.getOrCreateFahipayDeviceUuid()
|
||||||
|
val idCard = binding.etUsername.text.toString().trim()
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val authId = withContext(Dispatchers.IO) { flow.verifyTotp(code, deviceUuid) }
|
||||||
|
val cookieValue = flow.getSessionCookieValue() ?: ""
|
||||||
|
finishFahipayLogin(flow, FahipaySession(authId, cookieValue), idCard, password, store)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "OTP verification failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
} finally {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun finishFahipayLogin(
|
||||||
|
flow: FahipayLoginFlow,
|
||||||
|
session: FahipaySession,
|
||||||
|
idCard: String,
|
||||||
|
password: String,
|
||||||
|
store: CredentialStore
|
||||||
|
) {
|
||||||
|
val (profile, balance) = withContext(Dispatchers.IO) {
|
||||||
|
val p = flow.fetchProfile(session)
|
||||||
|
val b = flow.fetchBalance(session)
|
||||||
|
Pair(p, b)
|
||||||
|
}
|
||||||
|
val loginId = profile.profileId
|
||||||
|
val loginTag = "fahipay_$loginId"
|
||||||
|
val account = flow.buildAccount(profile, balance, loginTag)
|
||||||
|
store.saveFahipayCredentials(loginId, idCard, password)
|
||||||
|
store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
|
||||||
|
store.saveFahipayUserProfile(
|
||||||
|
loginId,
|
||||||
|
CredentialStore.FahipayUserProfile(
|
||||||
|
fullName = profile.fullName,
|
||||||
|
email = profile.email,
|
||||||
|
mobile = profile.mobile,
|
||||||
|
nid = profile.nid,
|
||||||
|
profileId = profile.profileId,
|
||||||
|
walletAccount = profile.walletAccount,
|
||||||
|
linkedAccounts = profile.linkedAccounts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AccountCache.saveFahipay(requireContext(), loginId, listOf(account))
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.fahipaySessions[loginId] = session
|
||||||
|
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||||
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
||||||
|
|
||||||
class LoginActivity : AppCompatActivity() {
|
class LoginActivity : AppCompatActivity() {
|
||||||
@@ -10,7 +12,13 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package sh.sar.basedbank.ui.onboarding
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.CountDownTimer
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -17,46 +21,73 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
|
|
||||||
private lateinit var binding: ActivityOnboardingBinding
|
private lateinit var binding: ActivityOnboardingBinding
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private var countDownTimer: CountDownTimer? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { view, insets ->
|
||||||
|
val statusBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.setPadding(view.paddingLeft, statusBar.top, view.paddingRight, view.paddingBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
val adapter = OnboardingPagerAdapter(this)
|
val adapter = OnboardingPagerAdapter(this)
|
||||||
binding.viewPager.adapter = adapter
|
binding.viewPager.adapter = adapter
|
||||||
|
|
||||||
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
|
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
|
||||||
|
// Disable tap-to-navigate on dots: touch listener must be on the individual
|
||||||
// Pre-select language chip without triggering the listener
|
// tab views inside SlidingTabStrip (child 0), because they consume ACTION_DOWN
|
||||||
val savedLang = prefs.getString("language", null)
|
// before the TabLayout's own touch listener ever fires.
|
||||||
binding.languageChipGroup.setOnCheckedStateChangeListener(null)
|
val tabStrip = binding.dotsIndicator.getChildAt(0) as? android.view.ViewGroup
|
||||||
when (savedLang) {
|
tabStrip?.let {
|
||||||
"en" -> binding.chipEnglish.isChecked = true
|
for (i in 0 until it.childCount) {
|
||||||
"dv" -> binding.chipDhivehi.isChecked = true
|
it.getChildAt(i).setOnTouchListener { _, _ -> true }
|
||||||
}
|
|
||||||
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
|
|
||||||
if (checkedIds.isNotEmpty()) {
|
|
||||||
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-select language button without triggering the listener
|
||||||
|
val savedLang = prefs.getString("language", null) ?: "en".also { selectLanguage(it) }
|
||||||
|
binding.languageToggle.clearOnButtonCheckedListeners()
|
||||||
|
when (savedLang) {
|
||||||
|
"en" -> binding.btnLangEnglish.isChecked = true
|
||||||
|
"dv" -> binding.btnLangDhivehi.isChecked = true
|
||||||
|
}
|
||||||
|
binding.languageToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (isChecked) selectLanguage(if (checkedId == R.id.btnLangEnglish) "en" else "dv")
|
||||||
|
}
|
||||||
|
|
||||||
|
supportFragmentManager.setFragmentResultListener(OnboardingFragment.RESULT_SCROLLED_TO_BOTTOM, this) { _, _ ->
|
||||||
|
startGetStartedCountdown()
|
||||||
|
}
|
||||||
|
|
||||||
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||||
override fun onPageSelected(position: Int) {
|
override fun onPageSelected(position: Int) {
|
||||||
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE
|
binding.languageSection.visibility = if (position == 0) View.VISIBLE else View.GONE
|
||||||
// Block forward swipe on slide 1 until security is set up
|
binding.viewPager.isUserInputEnabled = when {
|
||||||
if (position == 1) {
|
position > 2 -> false
|
||||||
binding.viewPager.isUserInputEnabled =
|
position == 1 -> prefs.getString("security_method", null) != null
|
||||||
prefs.getString("security_method", null) != null
|
else -> true
|
||||||
} else {
|
|
||||||
binding.viewPager.isUserInputEnabled = true
|
|
||||||
}
|
}
|
||||||
updateButtons(position, adapter.itemCount)
|
updateButtons(position, adapter.itemCount)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
binding.languageChipGroup.visibility = View.VISIBLE
|
binding.languageSection.visibility = View.VISIBLE
|
||||||
updateButtons(0, adapter.itemCount)
|
updateButtons(0, adapter.itemCount)
|
||||||
|
|
||||||
binding.btnNext.setOnClickListener {
|
binding.btnNext.setOnClickListener {
|
||||||
@@ -71,10 +102,21 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
countDownTimer?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
// Called by SecuritySetupFragment when setup is complete
|
// Called by SecuritySetupFragment when setup is complete
|
||||||
override fun onSecuritySetupComplete() {
|
override fun onSecuritySetupComplete() {
|
||||||
binding.viewPager.isUserInputEnabled = true
|
binding.viewPager.isUserInputEnabled = true
|
||||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by SecuritySetupFragment when user resets to reconfigure
|
||||||
|
override fun onSecuritySetupReset() {
|
||||||
|
binding.viewPager.isUserInputEnabled = false
|
||||||
|
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectLanguage(lang: String) {
|
private fun selectLanguage(lang: String) {
|
||||||
@@ -83,23 +125,34 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startGetStartedCountdown() {
|
||||||
|
binding.btnGetStarted.isEnabled = false
|
||||||
|
countDownTimer?.cancel()
|
||||||
|
countDownTimer = object : CountDownTimer(5000, 1000) {
|
||||||
|
override fun onTick(millisUntilFinished: Long) {
|
||||||
|
val seconds = (millisUntilFinished / 1000 + 1).toInt()
|
||||||
|
binding.btnGetStarted.text = "${getString(R.string.get_started)} ($seconds)"
|
||||||
|
}
|
||||||
|
override fun onFinish() {
|
||||||
|
binding.btnGetStarted.text = getString(R.string.get_started)
|
||||||
|
binding.btnGetStarted.isEnabled = true
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateButtons(position: Int, count: Int) {
|
private fun updateButtons(position: Int, count: Int) {
|
||||||
val langSelected = prefs.getString("language", null) != null
|
val langSelected = prefs.getString("language", null) != null
|
||||||
val securityDone = prefs.getString("security_method", null) != null
|
val securityDone = prefs.getString("security_method", null) != null
|
||||||
val isLast = position == count - 1
|
val isLast = position == count - 1
|
||||||
|
|
||||||
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
|
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
|
||||||
|
if (isLast) binding.btnGetStarted.isEnabled = false
|
||||||
|
|
||||||
// Hide Next on slide 1 until security is done (avoids a disabled-button-with-no-explanation)
|
binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
|
||||||
binding.btnNext.visibility = when {
|
|
||||||
isLast -> View.GONE
|
|
||||||
position == 1 && !securityDone -> View.GONE
|
|
||||||
else -> View.VISIBLE
|
|
||||||
}
|
|
||||||
binding.btnNext.isEnabled = when (position) {
|
binding.btnNext.isEnabled = when (position) {
|
||||||
0 -> langSelected
|
0 -> langSelected
|
||||||
1 -> securityDone
|
1 -> securityDone
|
||||||
else -> true
|
else -> true // position 2 (configure) has no gate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package sh.sar.basedbank.ui.onboarding
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||||
|
|
||||||
|
class OnboardingConfigureFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentOnboardingConfigureBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentOnboardingConfigureBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// Navigation — default Drawer
|
||||||
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
|
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||||
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme — default System
|
||||||
|
val savedTheme = prefs.getString("theme", "system")
|
||||||
|
binding.themeToggle.check(when (savedTheme) {
|
||||||
|
"light" -> R.id.btnThemeLight
|
||||||
|
"dark" -> R.id.btnThemeDark
|
||||||
|
else -> R.id.btnThemeSystem
|
||||||
|
})
|
||||||
|
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val (key, mode) = when (checkedId) {
|
||||||
|
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
prefs.edit().putString("theme", key).apply()
|
||||||
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biometrics
|
||||||
|
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
if (canUseBiometrics) {
|
||||||
|
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
binding.switchBiometrics.isChecked = unlockEnabled
|
||||||
|
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
|
||||||
|
|
||||||
|
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = isChecked
|
||||||
|
if (!isChecked) {
|
||||||
|
binding.switchBiometricsTransfer.isChecked = false
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||||
|
binding.switchBiometrics.isEnabled = false
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block screenshots — default on
|
||||||
|
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||||
|
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||||
|
applyFlagSecure(blockScreenshots)
|
||||||
|
binding.switchBlockScreenshots.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("block_screenshots", isChecked).apply()
|
||||||
|
applyFlagSecure(isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFlagSecure(enabled: Boolean) {
|
||||||
|
val win = activity?.window ?: return
|
||||||
|
if (enabled) win.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
else win.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import android.widget.ScrollView
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
|
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
|
||||||
@@ -12,6 +14,7 @@ class OnboardingFragment : Fragment() {
|
|||||||
|
|
||||||
private var _binding: FragmentOnboardingSlideBinding? = null
|
private var _binding: FragmentOnboardingSlideBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
private var scrolledToBottom = false
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
|
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
|
||||||
@@ -19,17 +22,45 @@ class OnboardingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val title = requireArguments().getString(ARG_TITLE, "")
|
val titleRes = requireArguments().getInt(ARG_TITLE)
|
||||||
val desc = requireArguments().getString(ARG_DESC, "")
|
val descRes = requireArguments().getInt(ARG_DESC)
|
||||||
val icon = requireArguments().getInt(ARG_ICON, 0)
|
val icon = requireArguments().getInt(ARG_ICON, 0)
|
||||||
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
|
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
|
||||||
|
val isLast = requireArguments().getBoolean(ARG_IS_LAST, false)
|
||||||
|
|
||||||
|
binding.icon.visibility = if (isLast) View.GONE else View.VISIBLE
|
||||||
binding.icon.setImageResource(icon)
|
binding.icon.setImageResource(icon)
|
||||||
binding.title.text = title
|
binding.title.text = getString(titleRes)
|
||||||
binding.description.text = desc
|
binding.description.text = getString(descRes)
|
||||||
|
binding.description.gravity = if (isLast) android.view.Gravity.START else android.view.Gravity.CENTER
|
||||||
|
|
||||||
// On the first slide, show the two placeholder cards for upcoming banks
|
// On the first slide, show the two placeholder cards for upcoming banks
|
||||||
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
|
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
if (isLast) setupScrollToBottomDetection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupScrollToBottomDetection() {
|
||||||
|
val scrollView = binding.scrollView
|
||||||
|
// If content fits without scrolling, fire immediately after layout
|
||||||
|
scrollView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
scrollView.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
val child = scrollView.getChildAt(0) ?: return
|
||||||
|
if (child.height <= scrollView.height) notifyScrolledToBottom()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
scrollView.setOnScrollChangeListener { v, _, scrollY, _, _ ->
|
||||||
|
val sv = v as ScrollView
|
||||||
|
val child = sv.getChildAt(0) ?: return@setOnScrollChangeListener
|
||||||
|
if (scrollY + sv.height >= child.height) notifyScrolledToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyScrolledToBottom() {
|
||||||
|
if (scrolledToBottom) return
|
||||||
|
scrolledToBottom = true
|
||||||
|
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
@@ -38,17 +69,20 @@ class OnboardingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val RESULT_SCROLLED_TO_BOTTOM = "scroll_to_bottom"
|
||||||
private const val ARG_TITLE = "title"
|
private const val ARG_TITLE = "title"
|
||||||
private const val ARG_DESC = "desc"
|
private const val ARG_DESC = "desc"
|
||||||
private const val ARG_ICON = "icon"
|
private const val ARG_ICON = "icon"
|
||||||
private const val ARG_IS_FIRST = "is_first"
|
private const val ARG_IS_FIRST = "is_first"
|
||||||
|
private const val ARG_IS_LAST = "is_last"
|
||||||
|
|
||||||
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
|
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
|
||||||
arguments = bundleOf(
|
arguments = bundleOf(
|
||||||
ARG_TITLE to slide.title,
|
ARG_TITLE to slide.titleRes,
|
||||||
ARG_DESC to slide.description,
|
ARG_DESC to slide.descRes,
|
||||||
ARG_ICON to slide.iconRes,
|
ARG_ICON to slide.iconRes,
|
||||||
ARG_IS_FIRST to slide.isFirst
|
ARG_IS_FIRST to slide.isFirst,
|
||||||
|
ARG_IS_LAST to slide.isLast
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,36 +9,39 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
|
|||||||
|
|
||||||
private val slides = listOf(
|
private val slides = listOf(
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
title = activity.getString(R.string.onboarding_title_1),
|
titleRes = R.string.onboarding_title_1,
|
||||||
description = activity.getString(R.string.onboarding_desc_1),
|
descRes = R.string.onboarding_desc_1,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_launcher_foreground,
|
||||||
isFirst = true
|
isFirst = true
|
||||||
),
|
),
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
title = activity.getString(R.string.onboarding_title_2),
|
titleRes = R.string.onboarding_title_2,
|
||||||
description = activity.getString(R.string.onboarding_desc_2),
|
descRes = R.string.onboarding_desc_2,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_launcher_foreground,
|
||||||
isFirst = false
|
isFirst = false
|
||||||
),
|
),
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
title = activity.getString(R.string.onboarding_title_3),
|
titleRes = R.string.onboarding_title_3,
|
||||||
description = activity.getString(R.string.onboarding_desc_3),
|
descRes = R.string.onboarding_desc_3,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_launcher_foreground,
|
||||||
isFirst = false
|
isFirst = false,
|
||||||
|
isLast = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun getItemCount() = slides.size
|
override fun getItemCount() = slides.size + 1 // +1 for OnboardingConfigureFragment at position 2
|
||||||
|
|
||||||
override fun createFragment(position: Int): Fragment = when (position) {
|
override fun createFragment(position: Int): Fragment = when (position) {
|
||||||
1 -> SecuritySetupFragment()
|
1 -> SecuritySetupFragment()
|
||||||
else -> OnboardingFragment.newInstance(slides[position])
|
2 -> OnboardingConfigureFragment()
|
||||||
|
else -> OnboardingFragment.newInstance(slides[position - if (position > 2) 1 else 0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class OnboardingSlide(
|
data class OnboardingSlide(
|
||||||
val title: String,
|
val titleRes: Int,
|
||||||
val description: String,
|
val descRes: Int,
|
||||||
val iconRes: Int,
|
val iconRes: Int,
|
||||||
val isFirst: Boolean
|
val isFirst: Boolean,
|
||||||
|
val isLast: Boolean = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,17 +86,21 @@ class PatternView @JvmOverloads constructor(
|
|||||||
if (errorState) return false
|
if (errorState) return false
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(true)
|
||||||
recording = true; selected.clear()
|
recording = true; selected.clear()
|
||||||
hit(event.x, event.y)
|
hit(event.x, event.y)
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(true)
|
||||||
touchX = event.x; touchY = event.y
|
touchX = event.x; touchY = event.y
|
||||||
hit(event.x, event.y)
|
hit(event.x, event.y)
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_UP -> {
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(false)
|
||||||
recording = false
|
recording = false
|
||||||
invalidate()
|
invalidate()
|
||||||
onPatternComplete?.invoke(selected.map { it.index })
|
if (event.action == MotionEvent.ACTION_UP)
|
||||||
|
onPatternComplete?.invoke(selected.map { it.index })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|||||||
@@ -7,18 +7,20 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.biometric.BiometricManager
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||||
import java.security.MessageDigest
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
class SecuritySetupFragment : Fragment() {
|
class SecuritySetupFragment : Fragment() {
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
fun onSecuritySetupComplete()
|
fun onSecuritySetupComplete()
|
||||||
|
fun onSecuritySetupReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -31,7 +33,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
private var _b: FragmentSecuritySetupBinding? = null
|
private var _b: FragmentSecuritySetupBinding? = null
|
||||||
private val b get() = _b!!
|
private val b get() = _b!!
|
||||||
|
|
||||||
private enum class Step { CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM, BIOMETRIC }
|
private enum class Step { CONFIGURED, CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM }
|
||||||
|
|
||||||
private var step = Step.CHOOSE
|
private var step = Step.CHOOSE
|
||||||
private val pinDigits = mutableListOf<Int>()
|
private val pinDigits = mutableListOf<Int>()
|
||||||
@@ -46,9 +48,6 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false
|
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false
|
||||||
if (!changeMode && prefs.getString("security_method", null) != null) {
|
|
||||||
(activity as? Callback)?.onSecuritySetupComplete()
|
|
||||||
}
|
|
||||||
|
|
||||||
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
|
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
|
||||||
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
|
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
|
||||||
@@ -60,20 +59,21 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
|
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
|
||||||
|
b.btnChangeLock.setOnClickListener {
|
||||||
|
prefs.edit().remove("security_method").apply()
|
||||||
|
(activity as? Callback)?.onSecuritySetupReset()
|
||||||
|
goTo(Step.CHOOSE)
|
||||||
|
}
|
||||||
|
|
||||||
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) }
|
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) }
|
||||||
|
|
||||||
b.btnEnableBiometrics.setOnClickListener {
|
|
||||||
prefs.edit().putBoolean("biometrics_enabled", true).apply()
|
|
||||||
finishSetup()
|
|
||||||
}
|
|
||||||
b.btnSkipBiometrics.setOnClickListener {
|
|
||||||
prefs.edit().putBoolean("biometrics_enabled", false).apply()
|
|
||||||
finishSetup()
|
|
||||||
}
|
|
||||||
|
|
||||||
buildNumpad()
|
buildNumpad()
|
||||||
goTo(Step.CHOOSE)
|
|
||||||
|
if (!changeMode && prefs.getString("security_method", null) != null) {
|
||||||
|
goTo(Step.CONFIGURED)
|
||||||
|
} else {
|
||||||
|
goTo(Step.CHOOSE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNumpad() {
|
private fun buildNumpad() {
|
||||||
@@ -142,7 +142,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
Step.PIN_CONFIRM -> {
|
Step.PIN_CONFIRM -> {
|
||||||
if (entered == firstPin) {
|
if (entered == firstPin) {
|
||||||
saveCredential("pin", entered)
|
saveCredential("pin", entered)
|
||||||
goToBiometricOrFinish()
|
finishSetup()
|
||||||
} else {
|
} else {
|
||||||
b.tvPinDots.text = getString(R.string.pin_no_match)
|
b.tvPinDots.text = getString(R.string.pin_no_match)
|
||||||
pinDigits.clear()
|
pinDigits.clear()
|
||||||
@@ -170,7 +170,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
Step.PATTERN_CONFIRM -> {
|
Step.PATTERN_CONFIRM -> {
|
||||||
if (pattern == firstPattern) {
|
if (pattern == firstPattern) {
|
||||||
saveCredential("pattern", pattern.joinToString(""))
|
saveCredential("pattern", pattern.joinToString(""))
|
||||||
goToBiometricOrFinish()
|
finishSetup()
|
||||||
} else {
|
} else {
|
||||||
b.patternView.showError()
|
b.patternView.showError()
|
||||||
b.tvPatternStatus.text = getString(R.string.pattern_no_match)
|
b.tvPatternStatus.text = getString(R.string.pattern_no_match)
|
||||||
@@ -185,29 +185,12 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun goToBiometricOrFinish() {
|
|
||||||
// In change mode, biometrics is managed from Settings — skip this step
|
|
||||||
if (arguments?.getBoolean(ARG_CHANGE_MODE, false) == true) {
|
|
||||||
finishSetup()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val canAuth = BiometricManager.from(requireContext())
|
|
||||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
|
|
||||||
if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) {
|
|
||||||
goTo(Step.BIOMETRIC)
|
|
||||||
} else {
|
|
||||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit().putBoolean("biometrics_enabled", false).apply()
|
|
||||||
finishSetup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun goTo(s: Step) {
|
private fun goTo(s: Step) {
|
||||||
step = s
|
step = s
|
||||||
|
b.viewConfigured.visibility = if (s == Step.CONFIGURED) View.VISIBLE else View.GONE
|
||||||
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE
|
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE
|
||||||
b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE
|
b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE
|
||||||
b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE
|
b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE
|
||||||
b.viewBiometric.visibility = if (s == Step.BIOMETRIC) View.VISIBLE else View.GONE
|
|
||||||
|
|
||||||
when (s) {
|
when (s) {
|
||||||
Step.PIN_ENTER -> {
|
Step.PIN_ENTER -> {
|
||||||
@@ -231,20 +214,27 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
private fun saveCredential(method: String, input: String) {
|
private fun saveCredential(method: String, input: String) {
|
||||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
||||||
val hash = sha256(saltB64 + input)
|
val hash = pbkdf2(input, salt)
|
||||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
||||||
.putString("security_method", method)
|
.putString("security_method", method)
|
||||||
.putString("security_salt", saltB64)
|
|
||||||
.putString("security_hash", hash)
|
|
||||||
.apply()
|
.apply()
|
||||||
|
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
|
private fun pbkdf2(input: String, salt: ByteArray): String {
|
||||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256)
|
||||||
|
return try {
|
||||||
|
val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||||
|
Base64.encodeToString(hash, Base64.NO_WRAP)
|
||||||
|
} finally {
|
||||||
|
spec.clearPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun finishSetup() {
|
private fun finishSetup() {
|
||||||
val cb = activity as? Callback
|
val cb = activity as? Callback
|
||||||
if (cb != null) {
|
if (cb != null) {
|
||||||
|
goTo(Step.CONFIGURED)
|
||||||
cb.onSecuritySetupComplete()
|
cb.onSecuritySetupComplete()
|
||||||
} else {
|
} else {
|
||||||
parentFragmentManager.popBackStack()
|
parentFragmentManager.popBackStack()
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ object AccountCache {
|
|||||||
|
|
||||||
private const val PREFS = "account_cache"
|
private const val PREFS = "account_cache"
|
||||||
private const val KEY_MIB = "mib_accounts"
|
private const val KEY_MIB = "mib_accounts"
|
||||||
private const val KEY_BML = "bml_accounts"
|
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||||
|
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||||
|
|
||||||
fun save(context: Context, accounts: List<MibAccount>) {
|
fun save(context: Context, accounts: List<MibAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (acc in accounts) {
|
for (acc in accounts) {
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
|
put("bank", acc.bank)
|
||||||
put("profileName", acc.profileName)
|
put("profileName", acc.profileName)
|
||||||
put("profileType", acc.profileType)
|
put("profileType", acc.profileType)
|
||||||
|
put("cifType", acc.cifType)
|
||||||
put("accountNumber", acc.accountNumber)
|
put("accountNumber", acc.accountNumber)
|
||||||
put("accountBriefName", acc.accountBriefName)
|
put("accountBriefName", acc.accountBriefName)
|
||||||
put("currencyName", acc.currencyName)
|
put("currencyName", acc.currencyName)
|
||||||
@@ -32,10 +35,10 @@ object AccountCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY_MIB, arr.toString()).apply()
|
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBml(context: Context, accounts: List<MibAccount>) {
|
fun saveBml(context: Context, loginId: String, accounts: List<MibAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (acc in accounts) {
|
for (acc in accounts) {
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
@@ -55,62 +58,123 @@ object AccountCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY_BML, arr.toString()).apply()
|
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context): List<MibAccount> {
|
fun loadBml(context: Context, loginId: String): List<MibAccount> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_BML, null) ?: return emptyList()
|
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibAccount(
|
MibAccount(
|
||||||
profileName = o.optString("profileName"),
|
bank = "BML",
|
||||||
profileType = o.optString("profileType"),
|
profileName = o.optString("profileName"),
|
||||||
accountNumber = o.optString("accountNumber"),
|
profileType = o.optString("profileType"),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountBriefName = o.optString("accountBriefName"),
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
currencyName = o.optString("currencyName"),
|
currencyName = o.optString("currencyName"),
|
||||||
accountTypeName = o.optString("accountTypeName"),
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
availableBalance = o.optString("availableBalance"),
|
availableBalance = o.optString("availableBalance"),
|
||||||
currentBalance = o.optString("currentBalance"),
|
currentBalance = o.optString("currentBalance"),
|
||||||
blockedAmount = o.optString("blockedAmount"),
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
mvrBalance = o.optString("mvrBalance"),
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
statusDesc = o.optString("statusDesc"),
|
statusDesc = o.optString("statusDesc"),
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = o.optString("loginTag"),
|
loginTag = o.optString("loginTag"),
|
||||||
internalId = o.optString("internalId", "")
|
internalId = o.optString("internalId", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
|
||||||
|
loginIds.flatMap { loadBml(context, it) }
|
||||||
|
|
||||||
|
fun saveFahipay(context: Context, loginId: String, accounts: List<MibAccount>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (acc in accounts) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("profileName", acc.profileName)
|
||||||
|
put("profileType", acc.profileType)
|
||||||
|
put("accountNumber", acc.accountNumber)
|
||||||
|
put("accountBriefName", acc.accountBriefName)
|
||||||
|
put("currencyName", acc.currencyName)
|
||||||
|
put("accountTypeName", acc.accountTypeName)
|
||||||
|
put("availableBalance", acc.availableBalance)
|
||||||
|
put("currentBalance", acc.currentBalance)
|
||||||
|
put("blockedAmount", acc.blockedAmount)
|
||||||
|
put("mvrBalance", acc.mvrBalance)
|
||||||
|
put("statusDesc", acc.statusDesc)
|
||||||
|
put("loginTag", acc.loginTag)
|
||||||
|
put("internalId", acc.internalId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFahipay(context: Context, loginId: String): List<MibAccount> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(fahipayKey(loginId), null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
MibAccount(
|
||||||
|
bank = "FAHIPAY",
|
||||||
|
profileName = o.optString("profileName"),
|
||||||
|
profileType = o.optString("profileType"),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
|
currencyName = o.optString("currencyName"),
|
||||||
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
|
availableBalance = o.optString("availableBalance"),
|
||||||
|
currentBalance = o.optString("currentBalance"),
|
||||||
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
|
statusDesc = o.optString("statusDesc"),
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = o.optString("loginTag"),
|
||||||
|
internalId = o.optString("internalId", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFahipay(context: Context, loginIds: List<String>): List<MibAccount> =
|
||||||
|
loginIds.flatMap { loadFahipay(context, it) }
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<MibAccount> {
|
fun load(context: Context): List<MibAccount> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_MIB, null) ?: return emptyList()
|
.getString(KEY_MIB, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibAccount(
|
MibAccount(
|
||||||
profileName = o.optString("profileName"),
|
bank = o.optString("bank", "MIB"),
|
||||||
profileType = o.optString("profileType"),
|
profileName = o.optString("profileName"),
|
||||||
accountNumber = o.optString("accountNumber"),
|
profileType = o.optString("profileType"),
|
||||||
|
cifType = o.optString("cifType", ""),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountBriefName = o.optString("accountBriefName"),
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
currencyName = o.optString("currencyName"),
|
currencyName = o.optString("currencyName"),
|
||||||
accountTypeName = o.optString("accountTypeName"),
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
availableBalance = o.optString("availableBalance"),
|
availableBalance = o.optString("availableBalance"),
|
||||||
currentBalance = o.optString("currentBalance"),
|
currentBalance = o.optString("currentBalance"),
|
||||||
blockedAmount = o.optString("blockedAmount"),
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
mvrBalance = o.optString("mvrBalance"),
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
statusDesc = o.optString("statusDesc"),
|
statusDesc = o.optString("statusDesc"),
|
||||||
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
|
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
|
||||||
loginTag = o.optString("loginTag"),
|
loginTag = o.optString("loginTag"),
|
||||||
profileId = o.optString("profileId", "")
|
profileId = o.optString("profileId", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard display model for the account history header card — produced by
|
||||||
|
* per-bank parsers, consumed by AccountHistoryAdapter with no bank logic.
|
||||||
|
*/
|
||||||
|
data class AccountHistoryDisplay(
|
||||||
|
val name: String,
|
||||||
|
val number: String,
|
||||||
|
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
|
||||||
|
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
|
||||||
|
val availableBalance: String, // formatted "CCY amount"
|
||||||
|
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
|
||||||
|
val blockedBalance: String? // null if zero or not applicable
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||||
|
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||||
|
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||||
|
|
||||||
|
object AccountHistoryParser {
|
||||||
|
|
||||||
|
fun from(account: MibAccount): AccountHistoryDisplay? = when (account.bank) {
|
||||||
|
"BML" -> BmlHistoryParser.displayData(account)
|
||||||
|
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||||
|
"MIB" -> MibHistoryParser.displayData(account)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
object AccountInputParser {
|
||||||
|
|
||||||
|
enum class InputType {
|
||||||
|
MIB_ACCOUNT, // 17 digits starting with 9
|
||||||
|
BML_ACCOUNT, // 13 digits starting with 7
|
||||||
|
PHONE, // 7 digits starting with 7 or 9
|
||||||
|
NATIONAL_ID, // A followed by 6 digits
|
||||||
|
EMAIL,
|
||||||
|
UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip spaces and remove a 960/+960 country code prefix, but only when
|
||||||
|
* the stripped result is exactly 7 digits (so "9603456" is left intact).
|
||||||
|
*/
|
||||||
|
fun normalize(input: String): String {
|
||||||
|
var s = input.replace(" ", "")
|
||||||
|
val stripped = when {
|
||||||
|
s.startsWith("+960") -> s.removePrefix("+960")
|
||||||
|
s.startsWith("960") -> s.removePrefix("960")
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (stripped != null && stripped.matches(Regex("^\\d{7}$"))) s = stripped
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detect(input: String): InputType {
|
||||||
|
val s = input.trim()
|
||||||
|
return when {
|
||||||
|
s.matches(Regex("^9\\d{16}$")) -> InputType.MIB_ACCOUNT
|
||||||
|
s.matches(Regex("^7\\d{12}$")) -> InputType.BML_ACCOUNT
|
||||||
|
s.matches(Regex("^[79]\\d{6}$")) -> InputType.PHONE
|
||||||
|
s.matches(Regex("^[Aa]\\d{6}$")) -> InputType.NATIONAL_ID
|
||||||
|
s.contains("@") -> InputType.EMAIL
|
||||||
|
else -> InputType.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
data class AccountListDisplay(
|
||||||
|
val name: String,
|
||||||
|
val number: String,
|
||||||
|
val typeLabel: String,
|
||||||
|
val balance: String,
|
||||||
|
val isCard: Boolean = false,
|
||||||
|
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
|
||||||
|
val statusLabel: String? = null // null = active; shown as status pill if set
|
||||||
|
)
|
||||||
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal file
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
|
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||||
|
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||||
|
|
||||||
|
object AccountListParser {
|
||||||
|
|
||||||
|
fun from(account: MibAccount): AccountListDisplay? = when (account.bank) {
|
||||||
|
"BML" -> BmlDashboardParser.displayData(account)
|
||||||
|
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||||
|
"MIB" -> MibAccountParser.displayData(account)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/src/main/java/sh/sar/basedbank/util/CacheEncryption.kt
Normal file
55
app/src/main/java/sh/sar/basedbank/util/CacheEncryption.kt
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared AES-256/GCM encryption for cache SharedPreferences and files.
|
||||||
|
* Uses the same AndroidKeyStore key as CredentialStore so no extra key material is created.
|
||||||
|
*/
|
||||||
|
internal object CacheEncryption {
|
||||||
|
|
||||||
|
private const val KEY_ALIAS = "basedbank_credential_key"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
|
||||||
|
fun encrypt(plaintext: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
val iv = cipher.iv
|
||||||
|
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(encoded: String): String {
|
||||||
|
val colon = encoded.indexOf(':')
|
||||||
|
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
|
||||||
|
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
|
||||||
|
return String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
||||||
|
ks.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
|
||||||
|
|
||||||
|
val spec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||||
|
.also { it.init(spec) }
|
||||||
|
.generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal file
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard display model for a contact row — produced by per-bank parsers,
|
||||||
|
* consumed by UI. No bank-specific logic in adapters or fragments.
|
||||||
|
*/
|
||||||
|
data class ContactDisplay(
|
||||||
|
val id: String, // internal contact ID (benefNo)
|
||||||
|
val name: String, // display nickname
|
||||||
|
val realName: String, // legal name — used for search
|
||||||
|
val accountNumber: String,
|
||||||
|
val categoryId: String, // for tab filtering
|
||||||
|
val network: TransferNetwork,
|
||||||
|
val bankColor: String,
|
||||||
|
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
||||||
|
val imageHash: String?,
|
||||||
|
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
||||||
|
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
||||||
|
val canTransfer: Boolean,
|
||||||
|
val canEdit: Boolean,
|
||||||
|
val canDelete: Boolean
|
||||||
|
)
|
||||||
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal file
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlContactParser
|
||||||
|
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
|
||||||
|
import sh.sar.basedbank.util.mibapi.MibContactParser
|
||||||
|
|
||||||
|
object ContactListParser {
|
||||||
|
|
||||||
|
fun from(contact: MibBeneficiary): ContactDisplay? = when {
|
||||||
|
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
|
||||||
|
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
|
||||||
|
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromList(contacts: List<MibBeneficiary>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
|
||||||
|
}
|
||||||
40
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal file
40
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behaviour dispatcher for contact operations.
|
||||||
|
* Routes add/delete to the correct bank API based on TransferNetwork.
|
||||||
|
* UI code never inspects the network or bank type directly.
|
||||||
|
*/
|
||||||
|
object ContactManager {
|
||||||
|
|
||||||
|
/** Deletes [contact] via the appropriate bank API. Returns true on success. */
|
||||||
|
suspend fun delete(contact: ContactDisplay, app: BasedBankApp): Boolean = when (contact.network) {
|
||||||
|
TransferNetwork.BML -> deleteBml(contact, app)
|
||||||
|
TransferNetwork.FAHIPAY -> false // Fahipay contacts are read-only
|
||||||
|
TransferNetwork.MIB, TransferNetwork.LOCAL, TransferNetwork.SWIFT -> deleteMib(contact, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||||
|
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
||||||
|
val contactId = contact.id.removePrefix("bml_")
|
||||||
|
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||||
|
val sess = app.anyMibSession() ?: return false
|
||||||
|
return try {
|
||||||
|
if (contact.profileId.isNotBlank()) {
|
||||||
|
val (loginId, profile) = app.mibProfilesMap.entries
|
||||||
|
.firstNotNullOfOrNull { (id, profiles) ->
|
||||||
|
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
|
||||||
|
} ?: (null to null)
|
||||||
|
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
|
}
|
||||||
|
MibContactsClient().deleteContact(sess, contact.id)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ object ContactsCache {
|
|||||||
put("profileId", c.profileId)
|
put("profileId", c.profileId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
prefs.putString(KEY_CONTACTS, contactsArr.toString())
|
prefs.putString(KEY_CONTACTS, CacheEncryption.encrypt(contactsArr.toString()))
|
||||||
|
|
||||||
val catArr = JSONArray()
|
val catArr = JSONArray()
|
||||||
for (cat in categories) {
|
for (cat in categories) {
|
||||||
@@ -47,7 +47,7 @@ object ContactsCache {
|
|||||||
put("numBenef", cat.numBenef)
|
put("numBenef", cat.numBenef)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
prefs.putString(KEY_CATEGORIES, catArr.toString())
|
prefs.putString(KEY_CATEGORIES, CacheEncryption.encrypt(catArr.toString()))
|
||||||
prefs.apply()
|
prefs.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +56,10 @@ object ContactsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun loadContacts(context: Context): List<MibBeneficiary> {
|
fun loadContacts(context: Context): List<MibBeneficiary> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_CONTACTS, null) ?: return emptyList()
|
.getString(KEY_CONTACTS, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
@@ -83,7 +84,9 @@ object ContactsCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBml(context: Context, contacts: List<MibBeneficiary>) {
|
private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
|
||||||
|
|
||||||
|
fun saveBml(context: Context, loginId: String, contacts: List<MibBeneficiary>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (c in contacts) {
|
for (c in contacts) {
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
@@ -98,17 +101,18 @@ object ContactsCache {
|
|||||||
put("benefStatus", c.benefStatus)
|
put("benefStatus", c.benefStatus)
|
||||||
put("transferCyDesc", c.transferCyDesc)
|
put("transferCyDesc", c.transferCyDesc)
|
||||||
put("benefCategoryId", c.benefCategoryId)
|
put("benefCategoryId", c.benefCategoryId)
|
||||||
|
put("profileId", c.profileId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString("bml_contacts", arr.toString()).apply()
|
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context): List<MibBeneficiary> {
|
fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString("bml_contacts", null) ?: return emptyList()
|
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibBeneficiary(
|
MibBeneficiary(
|
||||||
@@ -123,16 +127,81 @@ object ContactsCache {
|
|||||||
benefStatus = o.optString("benefStatus"),
|
benefStatus = o.optString("benefStatus"),
|
||||||
transferCyDesc = o.optString("transferCyDesc", "MVR"),
|
transferCyDesc = o.optString("transferCyDesc", "MVR"),
|
||||||
customerImgHash = null,
|
customerImgHash = null,
|
||||||
benefCategoryId = o.optString("benefCategoryId", "BML")
|
benefCategoryId = o.optString("benefCategoryId", "BML"),
|
||||||
|
profileId = o.optString("profileId", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBml(context: Context, loginIds: List<String>): List<MibBeneficiary> =
|
||||||
|
loginIds.flatMap { loadBml(context, it) }
|
||||||
|
|
||||||
|
fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (c in contacts) arr.put(JSONObject().apply {
|
||||||
|
put("benefNo", c.benefNo)
|
||||||
|
put("benefNickName", c.benefNickName)
|
||||||
|
put("benefAccount", c.benefAccount)
|
||||||
|
put("bankColor", c.bankColor)
|
||||||
|
put("benefBankName", c.benefBankName)
|
||||||
|
put("benefCategoryId", c.benefCategoryId)
|
||||||
|
})
|
||||||
|
prefs.putString("fahipay_contacts", CacheEncryption.encrypt(arr.toString()))
|
||||||
|
val catArr = JSONArray()
|
||||||
|
for (cat in categories) catArr.put(JSONObject().apply {
|
||||||
|
put("id", cat.id)
|
||||||
|
put("categoryName", cat.categoryName)
|
||||||
|
put("numBenef", cat.numBenef)
|
||||||
|
})
|
||||||
|
prefs.putString("fahipay_categories", CacheEncryption.encrypt(catArr.toString()))
|
||||||
|
prefs.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFahipay(context: Context): List<MibBeneficiary> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString("fahipay_contacts", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
MibBeneficiary(
|
||||||
|
benefNo = o.optString("benefNo"),
|
||||||
|
benefName = "",
|
||||||
|
benefNickName = o.optString("benefNickName"),
|
||||||
|
benefAccount = o.optString("benefAccount"),
|
||||||
|
benefType = "FAHIPAY",
|
||||||
|
bankColor = o.optString("bankColor", "#FF6B00"),
|
||||||
|
benefBankName = o.optString("benefBankName"),
|
||||||
|
bankCode = "",
|
||||||
|
benefStatus = "",
|
||||||
|
transferCyDesc = "",
|
||||||
|
customerImgHash = null,
|
||||||
|
benefCategoryId = o.optString("benefCategoryId"),
|
||||||
|
profileId = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFahipayCategories(context: Context): List<MibBeneficiaryCategory> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString("fahipay_categories", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
MibBeneficiaryCategory(o.optString("id"), o.optString("categoryName"), o.optInt("numBenef"))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
|
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_CATEGORIES, null) ?: return emptyList()
|
.getString(KEY_CATEGORIES, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
@@ -18,94 +20,459 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
||||||
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
||||||
|
data class FahipayCredentials(val idCard: String, val password: String)
|
||||||
|
|
||||||
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
|
|
||||||
|
|
||||||
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
|
fun getMibLoginIds(): List<String> {
|
||||||
val key = getOrCreateKey()
|
maybeMigrateLegacyMib()
|
||||||
prefs.edit()
|
val json = prefs.getString("mib_login_ids", null) ?: return emptyList()
|
||||||
.putString("mib_enc_username", encrypt(username, key))
|
return try {
|
||||||
.putString("mib_enc_password_hash", encrypt(passwordHash, key))
|
val arr = org.json.JSONArray(json)
|
||||||
.putString("mib_enc_otp_seed", encrypt(otpSeed, key))
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
.apply()
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibCredentials(): MibCredentials? {
|
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||||
val key = getOrCreateKey()
|
|
||||||
val encUsername = prefs.getString("mib_enc_username", null) ?: return null
|
private fun addMibLoginId(loginId: String) {
|
||||||
val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null
|
val ids = getMibLoginIds().toMutableList()
|
||||||
val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null
|
if (loginId !in ids) {
|
||||||
return try {
|
ids.add(loginId)
|
||||||
MibCredentials(
|
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
decrypt(encUsername, key),
|
|
||||||
decrypt(encHash, key),
|
|
||||||
decrypt(encSeed, key)
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearMibCredentials() {
|
private fun removeMibLoginId(loginId: String) {
|
||||||
|
val ids = getMibLoginIds().toMutableList()
|
||||||
|
if (ids.remove(loginId))
|
||||||
|
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMibCredentials(loginId: String, username: String, passwordHash: String, otpSeed: String) {
|
||||||
|
addMibLoginId(loginId)
|
||||||
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("mib_enc_username")
|
.putString("mib_${loginId}_enc_password_hash", encrypt(passwordHash, key))
|
||||||
.remove("mib_enc_password_hash")
|
.putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
|
||||||
.remove("mib_enc_otp_seed")
|
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
|
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
|
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||||
|
val encSeed = prefs.getString("mib_${loginId}_enc_otp_seed", null) ?: return null
|
||||||
|
return try {
|
||||||
|
MibCredentials(loginId, decrypt(encHash, key), decrypt(encSeed, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMibCredentials(loginId: String) {
|
||||||
|
removeMibLoginId(loginId)
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bml_enc_username", encrypt(username, key))
|
.remove("mib_${loginId}_enc_password_hash")
|
||||||
.putString("bml_enc_password", encrypt(password, key))
|
.remove("mib_${loginId}_enc_otp_seed")
|
||||||
.putString("bml_enc_otp_seed", encrypt(otpSeed, key))
|
.remove("mib_${loginId}_enc_key1")
|
||||||
|
.remove("mib_${loginId}_enc_key2")
|
||||||
|
.remove("mib_${loginId}_enc_app_id")
|
||||||
|
.remove("mib_${loginId}_all_profiles")
|
||||||
|
.remove("mib_${loginId}_enc_profile")
|
||||||
|
.remove("mib_${loginId}_enc_full_name")
|
||||||
|
.remove("mib_${loginId}_hidden_profile_ids")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBmlCredentials(): BmlCredentials? {
|
// ── MIB session keys (key1/key2) and app ID (per loginId) ────────────────
|
||||||
|
|
||||||
|
fun saveMibKeys(loginId: String, key1: String, key2: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encUsername = prefs.getString("bml_enc_username", null) ?: return null
|
prefs.edit()
|
||||||
val encPassword = prefs.getString("bml_enc_password", null) ?: return null
|
.putString("mib_${loginId}_enc_key1", encrypt(key1, key))
|
||||||
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
|
.putString("mib_${loginId}_enc_key2", encrypt(key2, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMibKeys(loginId: String): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encKey1 = prefs.getString("mib_${loginId}_enc_key1", null) ?: return null
|
||||||
|
val encKey2 = prefs.getString("mib_${loginId}_enc_key2", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMibAppId(loginId: String, id: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("mib_${loginId}_enc_app_id", encrypt(id, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMibAppId(loginId: String): String? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("mib_${loginId}_enc_app_id", null) ?: return null
|
||||||
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time migration: if old single-login MIB data exists, move it to per-loginId storage. */
|
||||||
|
private var migrationChecked = false
|
||||||
|
private fun maybeMigrateLegacyMib() {
|
||||||
|
if (migrationChecked) return
|
||||||
|
migrationChecked = true
|
||||||
|
if (prefs.contains("mib_login_ids")) return // already migrated
|
||||||
|
val encUsername = prefs.getString("mib_enc_username", null) ?: return
|
||||||
|
val key = try { getOrCreateKey() } catch (_: Exception) { return }
|
||||||
|
val loginId = try { decrypt(encUsername, key) } catch (_: Exception) { return }
|
||||||
|
val editor = prefs.edit()
|
||||||
|
// Migrate credentials
|
||||||
|
prefs.getString("mib_enc_password_hash", null)?.let { editor.putString("mib_${loginId}_enc_password_hash", it) }
|
||||||
|
prefs.getString("mib_enc_otp_seed", null)?.let { editor.putString("mib_${loginId}_enc_otp_seed", it) }
|
||||||
|
prefs.getString("mib_enc_key1", null)?.let { editor.putString("mib_${loginId}_enc_key1", it) }
|
||||||
|
prefs.getString("mib_enc_key2", null)?.let { editor.putString("mib_${loginId}_enc_key2", it) }
|
||||||
|
prefs.getString("mib_enc_app_id", null)?.let { editor.putString("mib_${loginId}_enc_app_id", it) }
|
||||||
|
prefs.getString("mib_all_profiles", null)?.let { editor.putString("mib_${loginId}_all_profiles", it) }
|
||||||
|
prefs.getString("mib_enc_profile", null)?.let { editor.putString("mib_${loginId}_enc_profile", it) }
|
||||||
|
prefs.getString("mib_enc_full_name", null)?.let { editor.putString("mib_${loginId}_enc_full_name", it) }
|
||||||
|
prefs.getStringSet("mib_hidden_profile_ids", null)?.let { editor.putStringSet("mib_${loginId}_hidden_profile_ids", it) }
|
||||||
|
// Register the login ID and clear legacy keys
|
||||||
|
editor.putString("mib_login_ids", org.json.JSONArray(listOf(loginId)).toString())
|
||||||
|
editor.remove("mib_enc_username")
|
||||||
|
editor.remove("mib_enc_password_hash")
|
||||||
|
editor.remove("mib_enc_otp_seed")
|
||||||
|
editor.remove("mib_enc_key1")
|
||||||
|
editor.remove("mib_enc_key2")
|
||||||
|
editor.remove("mib_enc_app_id")
|
||||||
|
editor.remove("mib_all_profiles")
|
||||||
|
editor.remove("mib_enc_profile")
|
||||||
|
editor.remove("mib_enc_full_name")
|
||||||
|
editor.remove("mib_hidden_profile_ids")
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BML login credentials (multi-login, keyed by loginId = username) ────────
|
||||||
|
|
||||||
|
fun getBmlLoginIds(): List<String> {
|
||||||
|
val json = prefs.getString("bml_login_ids", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(json)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
private fun addBmlLoginId(loginId: String) {
|
||||||
|
val ids = getBmlLoginIds().toMutableList()
|
||||||
|
if (loginId !in ids) {
|
||||||
|
ids.add(loginId)
|
||||||
|
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeBmlLoginId(loginId: String) {
|
||||||
|
val ids = getBmlLoginIds().toMutableList()
|
||||||
|
if (ids.remove(loginId))
|
||||||
|
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBmlCredentials(loginId: String, username: String, password: String, otpSeed: String) {
|
||||||
|
addBmlLoginId(loginId)
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("bml_${loginId}_enc_username", encrypt(username, key))
|
||||||
|
.putString("bml_${loginId}_enc_password", encrypt(password, key))
|
||||||
|
.putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||||
|
val encPassword = prefs.getString("bml_${loginId}_enc_password", null) ?: return null
|
||||||
|
val encSeed = prefs.getString("bml_${loginId}_enc_otp_seed", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
|
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
|
||||||
} catch (e: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlCredentials() {
|
fun clearBmlCredentials(loginId: String) {
|
||||||
|
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
|
||||||
|
removeBmlLoginId(loginId)
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_enc_username")
|
.remove("bml_${loginId}_enc_username")
|
||||||
.remove("bml_enc_password")
|
.remove("bml_${loginId}_enc_password")
|
||||||
.remove("bml_enc_otp_seed")
|
.remove("bml_${loginId}_enc_otp_seed")
|
||||||
|
.remove("bml_${loginId}_profiles")
|
||||||
|
.remove("bml_${loginId}_hidden_profile_ids")
|
||||||
|
// legacy single-profile session keys
|
||||||
|
.remove("bml_${loginId}_enc_token")
|
||||||
|
.remove("bml_${loginId}_enc_device_id")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBmlSession(accessToken: String, deviceId: String) {
|
// ── BML profiles (per loginId) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
|
||||||
|
val arr = org.json.JSONArray()
|
||||||
|
for (p in profiles) arr.put(org.json.JSONObject().apply {
|
||||||
|
put("profileId", p.profileId)
|
||||||
|
put("name", p.name)
|
||||||
|
put("type", p.type)
|
||||||
|
put("profileType", p.profileType)
|
||||||
|
put("autoActivated", p.autoActivated)
|
||||||
|
})
|
||||||
|
prefs.edit().putString("bml_${loginId}_profiles", arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfiles(loginId: String): List<BmlProfile> {
|
||||||
|
val raw = prefs.getString("bml_${loginId}_profiles", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BmlProfile(
|
||||||
|
profileId = o.optString("profileId"),
|
||||||
|
name = o.optString("name"),
|
||||||
|
type = o.optString("type"),
|
||||||
|
profileType = o.optString("profileType", "default"),
|
||||||
|
autoActivated = o.optBoolean("autoActivated", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getHiddenBmlProfileIds(loginId: String): Set<String> =
|
||||||
|
prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenBmlProfileIds(loginId: String, ids: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("bml_${loginId}_hidden_profile_ids", ids).apply()
|
||||||
|
|
||||||
|
// ── BML per-profile session token (keyed by profileId, a globally unique GUID) ──
|
||||||
|
|
||||||
|
fun saveBmlProfileSession(profileId: String, accessToken: String, deviceId: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bml_enc_token", encrypt(accessToken, key))
|
.putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
|
||||||
.putString("bml_enc_device_id", encrypt(deviceId, key))
|
.putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBmlSession(): Pair<String, String>? {
|
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encToken = prefs.getString("bml_enc_token", null) ?: return null
|
val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null
|
||||||
val encDeviceId = prefs.getString("bml_enc_device_id", null) ?: return null
|
val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
|
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlSession() {
|
fun clearBmlProfileSession(profileId: String) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_enc_token")
|
.remove("bml_profile_${profileId}_enc_token")
|
||||||
.remove("bml_enc_device_id")
|
.remove("bml_profile_${profileId}_enc_device_id")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Legacy single-profile BML session (kept for backward compat reading) ─
|
||||||
|
|
||||||
|
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
||||||
|
val encDeviceId = prefs.getString("bml_${loginId}_enc_device_id", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
|
||||||
|
|
||||||
|
fun getFahipayLoginIds(): List<String> {
|
||||||
|
maybeMigrateLegacyFahipay()
|
||||||
|
val json = prefs.getString("fahipay_login_ids", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(json)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
private fun addFahipayLoginId(loginId: String) {
|
||||||
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
|
if (loginId !in ids) {
|
||||||
|
ids.add(loginId)
|
||||||
|
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeFahipayLoginId(loginId: String) {
|
||||||
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
|
if (ids.remove(loginId))
|
||||||
|
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveFahipayCredentials(loginId: String, idCard: String, password: String) {
|
||||||
|
addFahipayLoginId(loginId)
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("fahipay_${loginId}_enc_id_card", encrypt(idCard, key))
|
||||||
|
.putString("fahipay_${loginId}_enc_password", encrypt(password, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFahipayCredentials(loginId: String): FahipayCredentials? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encId = prefs.getString("fahipay_${loginId}_enc_id_card", null) ?: return null
|
||||||
|
val encPw = prefs.getString("fahipay_${loginId}_enc_password", null) ?: return null
|
||||||
|
return try {
|
||||||
|
FahipayCredentials(decrypt(encId, key), decrypt(encPw, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFahipayCredentials(loginId: String) {
|
||||||
|
removeFahipayLoginId(loginId)
|
||||||
|
prefs.edit()
|
||||||
|
.remove("fahipay_${loginId}_enc_id_card")
|
||||||
|
.remove("fahipay_${loginId}_enc_password")
|
||||||
|
.remove("fahipay_${loginId}_enc_auth_id")
|
||||||
|
.remove("fahipay_${loginId}_enc_session_cookie")
|
||||||
|
.remove("fahipay_${loginId}_enc_profile")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fahipay session (authId + __Secure-sess cookie) (per loginId) ─────────
|
||||||
|
|
||||||
|
fun saveFahipaySession(loginId: String, authId: String, sessionCookie: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("fahipay_${loginId}_enc_auth_id", encrypt(authId, key))
|
||||||
|
.putString("fahipay_${loginId}_enc_session_cookie", encrypt(sessionCookie, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFahipaySession(loginId: String): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encAuth = prefs.getString("fahipay_${loginId}_enc_auth_id", null) ?: return null
|
||||||
|
val encCookie = prefs.getString("fahipay_${loginId}_enc_session_cookie", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encAuth, key), decrypt(encCookie, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─
|
||||||
|
|
||||||
|
fun getOrCreateFahipayDeviceUuid(): String {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("fahipay_enc_device_uuid", null)
|
||||||
|
if (enc != null) {
|
||||||
|
try { return decrypt(enc, key) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
val uuid = sh.sar.basedbank.api.fahipay.FahipayLoginFlow.generateDeviceUuid()
|
||||||
|
prefs.edit().putString("fahipay_enc_device_uuid", encrypt(uuid, key)).apply()
|
||||||
|
return uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fahipay user profile (per loginId) ────────────────────────────────────
|
||||||
|
|
||||||
|
data class FahipayUserProfile(
|
||||||
|
val fullName: String,
|
||||||
|
val email: String,
|
||||||
|
val mobile: String,
|
||||||
|
val nid: String,
|
||||||
|
val profileId: String,
|
||||||
|
val walletAccount: String,
|
||||||
|
val linkedAccounts: String
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveFahipayUserProfile(loginId: String, p: FahipayUserProfile) {
|
||||||
|
val json = org.json.JSONObject().apply {
|
||||||
|
put("fullName", p.fullName)
|
||||||
|
put("email", p.email)
|
||||||
|
put("mobile", p.mobile)
|
||||||
|
put("nid", p.nid)
|
||||||
|
put("profileId", p.profileId)
|
||||||
|
put("walletAccount", p.walletAccount)
|
||||||
|
put("linkedAccounts", p.linkedAccounts)
|
||||||
|
}.toString()
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("fahipay_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadFahipayUserProfile(loginId: String): FahipayUserProfile? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("fahipay_${loginId}_enc_profile", null) ?: return null
|
||||||
|
return try {
|
||||||
|
val o = org.json.JSONObject(decrypt(enc, key))
|
||||||
|
FahipayUserProfile(
|
||||||
|
fullName = o.optString("fullName"),
|
||||||
|
email = o.optString("email"),
|
||||||
|
mobile = o.optString("mobile"),
|
||||||
|
nid = o.optString("nid"),
|
||||||
|
profileId = o.optString("profileId"),
|
||||||
|
walletAccount = o.optString("walletAccount"),
|
||||||
|
linkedAccounts = o.optString("linkedAccounts", "{}")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-time migration: if old single-login Fahipay data exists, move it to per-loginId storage. */
|
||||||
|
private var fahipayMigrationChecked = false
|
||||||
|
private fun maybeMigrateLegacyFahipay() {
|
||||||
|
if (fahipayMigrationChecked) return
|
||||||
|
fahipayMigrationChecked = true
|
||||||
|
if (prefs.contains("fahipay_login_ids")) return // already migrated
|
||||||
|
val encProfile = prefs.getString("fahipay_enc_profile", null) ?: return
|
||||||
|
val key = try { getOrCreateKey() } catch (_: Exception) { return }
|
||||||
|
val loginId = try {
|
||||||
|
val o = org.json.JSONObject(decrypt(encProfile, key))
|
||||||
|
o.optString("profileId").takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) { null } ?: return
|
||||||
|
val editor = prefs.edit()
|
||||||
|
prefs.getString("fahipay_enc_id_card", null)?.let { editor.putString("fahipay_${loginId}_enc_id_card", it) }
|
||||||
|
prefs.getString("fahipay_enc_password", null)?.let { editor.putString("fahipay_${loginId}_enc_password", it) }
|
||||||
|
prefs.getString("fahipay_enc_auth_id", null)?.let { editor.putString("fahipay_${loginId}_enc_auth_id", it) }
|
||||||
|
prefs.getString("fahipay_enc_session_cookie", null)?.let { editor.putString("fahipay_${loginId}_enc_session_cookie", it) }
|
||||||
|
editor.putString("fahipay_${loginId}_enc_profile", encProfile)
|
||||||
|
editor.putString("fahipay_login_ids", org.json.JSONArray(listOf(loginId)).toString())
|
||||||
|
editor.remove("fahipay_enc_id_card")
|
||||||
|
editor.remove("fahipay_enc_password")
|
||||||
|
editor.remove("fahipay_enc_auth_id")
|
||||||
|
editor.remove("fahipay_enc_session_cookie")
|
||||||
|
editor.remove("fahipay_enc_profile")
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the PBKDF2-derived hash and its salt, encrypted with the AndroidKeyStore key.
|
||||||
|
* The lock method ("pin"/"pattern") remains in the app's "prefs" SharedPreferences and
|
||||||
|
* is not stored here.
|
||||||
|
*/
|
||||||
|
fun saveSecurityHash(salt: String, hash: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("security_enc_salt", encrypt(salt, key))
|
||||||
|
.putString("security_enc_hash", encrypt(hash, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSecurityHash(): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encSalt = prefs.getString("security_enc_salt", null) ?: return null
|
||||||
|
val encHash = prefs.getString("security_enc_hash", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encSalt, key), decrypt(encHash, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSecurityHash() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("security_enc_salt")
|
||||||
|
.remove("security_enc_hash")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User profile (PII) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class MibUserProfile(
|
data class MibUserProfile(
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
@@ -123,54 +490,122 @@ class CredentialStore(context: Context) {
|
|||||||
val birthdate: String
|
val birthdate: String
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveMibFullName(name: String) = prefs.edit().putString("mib_full_name", name).apply()
|
// ── MIB operating profiles (per loginId) ─────────────────────────────────
|
||||||
fun loadMibFullName(): String? = prefs.getString("mib_full_name", null)
|
|
||||||
|
|
||||||
fun saveBmlFullName(name: String) = prefs.edit().putString("bml_full_name", name).apply()
|
fun saveMibProfiles(loginId: String, profiles: List<sh.sar.basedbank.api.mib.MibProfile>) {
|
||||||
fun loadBmlFullName(): String? = prefs.getString("bml_full_name", null)
|
val arr = org.json.JSONArray()
|
||||||
|
for (p in profiles) {
|
||||||
fun saveMibUserProfile(p: MibUserProfile) {
|
arr.put(org.json.JSONObject().apply {
|
||||||
prefs.edit().putString("mib_full_name", p.fullName)
|
put("profileId", p.profileId)
|
||||||
.putString("mib_profile_username", p.username)
|
put("name", p.name)
|
||||||
.putString("mib_profile_email", p.email)
|
put("cifType", p.cifType)
|
||||||
.putString("mib_profile_mobile", p.mobile)
|
put("profileType", p.profileType)
|
||||||
.putString("mib_profile_enrolled", p.enrolled)
|
put("color", p.color)
|
||||||
.apply()
|
})
|
||||||
|
}
|
||||||
|
prefs.edit().putString("mib_${loginId}_all_profiles", arr.toString()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibUserProfile(): MibUserProfile? {
|
fun loadMibProfiles(loginId: String): List<sh.sar.basedbank.api.mib.MibProfile> {
|
||||||
val name = prefs.getString("mib_full_name", null) ?: return null
|
val raw = prefs.getString("mib_${loginId}_all_profiles", null) ?: return emptyList()
|
||||||
return MibUserProfile(
|
return try {
|
||||||
fullName = name,
|
val arr = org.json.JSONArray(raw)
|
||||||
username = prefs.getString("mib_profile_username", "") ?: "",
|
(0 until arr.length()).map { i ->
|
||||||
email = prefs.getString("mib_profile_email", "") ?: "",
|
val o = arr.getJSONObject(i)
|
||||||
mobile = prefs.getString("mib_profile_mobile", "") ?: "",
|
sh.sar.basedbank.api.mib.MibProfile(
|
||||||
enrolled = prefs.getString("mib_profile_enrolled", "") ?: ""
|
profileId = o.optString("profileId"),
|
||||||
)
|
customerProfileId = o.optString("profileId"),
|
||||||
|
annexId = "",
|
||||||
|
customerId = "",
|
||||||
|
name = o.optString("name"),
|
||||||
|
cifType = o.optString("cifType"),
|
||||||
|
profileType = o.optString("profileType"),
|
||||||
|
color = o.optString("color"),
|
||||||
|
customerImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBmlUserProfile(p: BmlUserProfile) {
|
fun saveMibFullName(loginId: String, name: String) {
|
||||||
prefs.edit().putString("bml_full_name", p.fullName)
|
val key = getOrCreateKey()
|
||||||
.putString("bml_profile_email", p.email)
|
prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(name, key)).apply()
|
||||||
.putString("bml_profile_mobile", p.mobile)
|
|
||||||
.putString("bml_profile_customer_id", p.customerId)
|
|
||||||
.putString("bml_profile_idcard", p.idCard)
|
|
||||||
.putString("bml_profile_birthdate", p.birthdate)
|
|
||||||
.apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBmlUserProfile(): BmlUserProfile? {
|
fun loadMibFullName(loginId: String): String? {
|
||||||
val name = prefs.getString("bml_full_name", null) ?: return null
|
val key = getOrCreateKey()
|
||||||
return BmlUserProfile(
|
val enc = prefs.getString("mib_${loginId}_enc_full_name", null) ?: return null
|
||||||
fullName = name,
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
email = prefs.getString("bml_profile_email", "") ?: "",
|
|
||||||
mobile = prefs.getString("bml_profile_mobile", "") ?: "",
|
|
||||||
customerId = prefs.getString("bml_profile_customer_id", "") ?: "",
|
|
||||||
idCard = prefs.getString("bml_profile_idcard", "") ?: "",
|
|
||||||
birthdate = prefs.getString("bml_profile_birthdate", "") ?: ""
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveMibUserProfile(loginId: String, p: MibUserProfile) {
|
||||||
|
val json = JSONObject().apply {
|
||||||
|
put("fullName", p.fullName)
|
||||||
|
put("username", p.username)
|
||||||
|
put("email", p.email)
|
||||||
|
put("mobile", p.mobile)
|
||||||
|
put("enrolled", p.enrolled)
|
||||||
|
}.toString()
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||||
|
prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(p.fullName, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMibUserProfile(loginId: String): MibUserProfile? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("mib_${loginId}_enc_profile", null) ?: return null
|
||||||
|
return try {
|
||||||
|
val o = JSONObject(decrypt(enc, key))
|
||||||
|
MibUserProfile(
|
||||||
|
fullName = o.optString("fullName"),
|
||||||
|
username = o.optString("username"),
|
||||||
|
email = o.optString("email"),
|
||||||
|
mobile = o.optString("mobile"),
|
||||||
|
enrolled = o.optString("enrolled")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBmlUserProfile(loginId: String, p: BmlUserProfile) {
|
||||||
|
val json = JSONObject().apply {
|
||||||
|
put("fullName", p.fullName)
|
||||||
|
put("email", p.email)
|
||||||
|
put("mobile", p.mobile)
|
||||||
|
put("customerId", p.customerId)
|
||||||
|
put("idCard", p.idCard)
|
||||||
|
put("birthdate", p.birthdate)
|
||||||
|
}.toString()
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("bml_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlUserProfile(loginId: String): BmlUserProfile? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("bml_${loginId}_enc_profile", null) ?: return null
|
||||||
|
return try {
|
||||||
|
val o = JSONObject(decrypt(enc, key))
|
||||||
|
BmlUserProfile(
|
||||||
|
fullName = o.optString("fullName"),
|
||||||
|
email = o.optString("email"),
|
||||||
|
mobile = o.optString("mobile"),
|
||||||
|
customerId = o.optString("customerId"),
|
||||||
|
idCard = o.optString("idCard"),
|
||||||
|
birthdate = o.optString("birthdate")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MIB profile visibility (per loginId) ─────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
|
||||||
|
fun getHiddenMibProfileIds(loginId: String): Set<String> =
|
||||||
|
prefs.getStringSet("mib_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
||||||
|
|
||||||
|
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun getOrCreateKey(): SecretKey {
|
private fun getOrCreateKey(): SecretKey {
|
||||||
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
||||||
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
|
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
|
||||||
@@ -193,16 +628,16 @@ class CredentialStore(context: Context) {
|
|||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
val iv = cipher.iv
|
val iv = cipher.iv
|
||||||
val ct = cipher.doFinal(plaintext.toByteArray())
|
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
|
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decrypt(encoded: String, key: SecretKey): String {
|
private fun decrypt(encoded: String, key: SecretKey): String {
|
||||||
val parts = encoded.split(":")
|
val colon = encoded.indexOf(':')
|
||||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
|
||||||
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
|
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
|
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
|
||||||
return String(cipher.doFinal(ct))
|
return String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ object FinancingCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY_MIB, arr.toString()).apply()
|
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
@@ -39,9 +39,10 @@ object FinancingCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<MibFinanceDeal> {
|
fun load(context: Context): List<MibFinanceDeal> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_MIB, null) ?: return emptyList()
|
.getString(KEY_MIB, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ object ForeignLimitsCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY, arr.toString()).apply()
|
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
@@ -47,9 +47,10 @@ object ForeignLimitsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<HomeViewModel.BmlLimitsData> {
|
fun load(context: Context): List<HomeViewModel.BmlLimitsData> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY, null) ?: return emptyList()
|
.getString(KEY, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val entry = arr.getJSONObject(i)
|
val entry = arr.getJSONObject(i)
|
||||||
|
|||||||
117
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal file
117
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
|
import sh.sar.basedbank.api.mib.Transaction
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates all bank-specific pagination state and fetch logic for account history.
|
||||||
|
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
|
||||||
|
* without knowing which bank it is talking to.
|
||||||
|
*/
|
||||||
|
class HistoryFetcher(private val account: MibAccount) {
|
||||||
|
|
||||||
|
private val isMib get() = account.bank == "MIB"
|
||||||
|
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||||
|
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||||
|
|
||||||
|
// MIB pagination
|
||||||
|
private var mibNextStart = 1
|
||||||
|
private var mibTotalCount = -1
|
||||||
|
|
||||||
|
// BML CASA pagination
|
||||||
|
private var bmlNextPage = 1
|
||||||
|
private var bmlTotalPages = -1
|
||||||
|
|
||||||
|
// BML card pagination (month-based)
|
||||||
|
private var cardMonthOffset = 0
|
||||||
|
|
||||||
|
// Fahipay pagination
|
||||||
|
private var fahipayNextStart = 0
|
||||||
|
private var fahipayTotal = -1
|
||||||
|
|
||||||
|
fun hasMore(): Boolean = when {
|
||||||
|
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
|
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
|
isBmlCard -> cardMonthOffset < 3
|
||||||
|
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<Transaction> = when {
|
||||||
|
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||||
|
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||||
|
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||||
|
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchFahipay(app: BasedBankApp): List<Transaction> {
|
||||||
|
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||||
|
val flow = FahipayLoginFlow()
|
||||||
|
flow.setSessionCookie(session.sessionCookie)
|
||||||
|
val (list, total) = flow.fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
start = fahipayNextStart
|
||||||
|
)
|
||||||
|
if (total > 0) fahipayTotal = total
|
||||||
|
fahipayNextStart += list.size
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<Transaction> {
|
||||||
|
val loginId = account.loginTag.removePrefix("mib_")
|
||||||
|
val session = app.mibSessions[loginId] ?: return emptyList()
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == account.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||||
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNo = account.accountNumber,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
start = mibNextStart,
|
||||||
|
pageSize = pageSize
|
||||||
|
)
|
||||||
|
if (total > 0) mibTotalCount = total
|
||||||
|
mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchBmlCard(app: BasedBankApp): List<Transaction> {
|
||||||
|
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||||
|
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||||
|
cardMonthOffset++
|
||||||
|
return BmlLoginFlow().fetchCardHistory(
|
||||||
|
session = session,
|
||||||
|
cardId = account.internalId,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
month = month
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchBmlCasa(app: BasedBankApp): List<Transaction> {
|
||||||
|
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||||
|
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||||
|
session = session,
|
||||||
|
accountId = account.internalId,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
page = bmlNextPage
|
||||||
|
)
|
||||||
|
if (totalPages > 0) bmlTotalPages = totalPages
|
||||||
|
bmlNextPage++
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/src/main/java/sh/sar/basedbank/util/MerchantIconCache.kt
Normal file
24
app/src/main/java/sh/sar/basedbank/util/MerchantIconCache.kt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object MerchantIconCache {
|
||||||
|
|
||||||
|
private fun file(context: Context, url: String) =
|
||||||
|
File(context.cacheDir, "micon_${url.replace(Regex("[^A-Za-z0-9]"), "_")}.png")
|
||||||
|
|
||||||
|
fun save(context: Context, url: String, bitmap: Bitmap) {
|
||||||
|
try {
|
||||||
|
file(context, url).outputStream().use {
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.PNG, 90, it)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(context: Context, url: String): Bitmap? = try {
|
||||||
|
BitmapFactory.decodeFile(file(context, url).absolutePath)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Persistent (non-cache) store for completed transfer receipts shown in Recent Transfers. */
|
||||||
|
object ReceiptStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "activities.json"
|
||||||
|
|
||||||
|
data class Entry(val data: TransferReceiptData, val savedAt: Long)
|
||||||
|
|
||||||
|
fun save(context: Context, receipt: TransferReceiptData) {
|
||||||
|
val existing = loadAll(context).toMutableList()
|
||||||
|
existing.add(0, Entry(receipt, System.currentTimeMillis()))
|
||||||
|
writeAll(context, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadAll(context: Context): List<Entry> {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
Entry(
|
||||||
|
data = TransferReceiptData(
|
||||||
|
bank = o.optString("bank", "MIB"),
|
||||||
|
amount = o.optString("amount"),
|
||||||
|
currency = o.optString("currency"),
|
||||||
|
fromLabel = o.optString("fromLabel"),
|
||||||
|
fromColorHex = o.optString("fromColorHex"),
|
||||||
|
fromProfileImageHash = o.optString("fromProfileImageHash").takeIf { it.isNotBlank() },
|
||||||
|
toLabel = o.optString("toLabel"),
|
||||||
|
toAccount = o.optString("toAccount"),
|
||||||
|
toBank = o.optString("toBank"),
|
||||||
|
remarks = o.optString("remarks"),
|
||||||
|
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||||
|
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||||
|
bmlFromName = o.optString("bmlFromName"),
|
||||||
|
bmlReference = o.optString("bmlReference"),
|
||||||
|
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||||
|
bmlMessage = o.optString("bmlMessage")
|
||||||
|
),
|
||||||
|
savedAt = o.optLong("savedAt", 0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) {
|
||||||
|
File(context.filesDir, FILE_NAME).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeAll(context: Context, items: List<Entry>) {
|
||||||
|
try {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for ((d, ts) in items) arr.put(JSONObject().apply {
|
||||||
|
put("bank", d.bank)
|
||||||
|
put("amount", d.amount)
|
||||||
|
put("currency", d.currency)
|
||||||
|
put("fromLabel", d.fromLabel)
|
||||||
|
put("fromColorHex", d.fromColorHex)
|
||||||
|
put("fromProfileImageHash", d.fromProfileImageHash ?: "")
|
||||||
|
put("toLabel", d.toLabel)
|
||||||
|
put("toAccount", d.toAccount)
|
||||||
|
put("toBank", d.toBank)
|
||||||
|
put("remarks", d.remarks)
|
||||||
|
put("mibReferenceNo", d.mibReferenceNo)
|
||||||
|
put("mibTransactionDate", d.mibTransactionDate)
|
||||||
|
put("bmlFromName", d.bmlFromName)
|
||||||
|
put("bmlReference", d.bmlReference)
|
||||||
|
put("bmlTimestamp", d.bmlTimestamp)
|
||||||
|
put("bmlMessage", d.bmlMessage)
|
||||||
|
put("savedAt", ts)
|
||||||
|
})
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ object RecentsCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY, arr.toString()).apply()
|
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(context: Context, accountNumber: String) {
|
fun remove(context: Context, accountNumber: String) {
|
||||||
@@ -54,7 +54,7 @@ object RecentsCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY, arr.toString()).apply()
|
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
@@ -62,9 +62,10 @@ object RecentsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<RecentPick> {
|
fun load(context: Context): List<RecentPick> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY, null) ?: return emptyList()
|
.getString(KEY, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
|
|||||||
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal file
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
/** App-unified term for the transfer routing network a contact uses. */
|
||||||
|
enum class TransferNetwork {
|
||||||
|
MIB, // MIB internal (both parties on MIB)
|
||||||
|
LOCAL, // local inter-bank via IPS (e.g. BML from MIB's side)
|
||||||
|
SWIFT, // international SWIFT
|
||||||
|
BML, // Bank of Maldives
|
||||||
|
FAHIPAY // Fahipay wallet
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
object BmlContactParser {
|
||||||
|
|
||||||
|
fun displayData(contact: MibBeneficiary) = ContactDisplay(
|
||||||
|
id = contact.benefNo,
|
||||||
|
name = contact.benefNickName,
|
||||||
|
realName = contact.benefName,
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
categoryId = contact.benefCategoryId,
|
||||||
|
network = TransferNetwork.BML,
|
||||||
|
bankColor = contact.bankColor,
|
||||||
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
imageHash = contact.customerImgHash,
|
||||||
|
profileId = contact.profileId,
|
||||||
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
canTransfer = true,
|
||||||
|
canEdit = true,
|
||||||
|
canDelete = true
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
|
||||||
|
object BmlDashboardParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all display fields for an account/card row in the accounts list.
|
||||||
|
* Handles both BML CASA accounts and BML prepaid/credit cards.
|
||||||
|
*/
|
||||||
|
fun displayData(account: MibAccount): AccountListDisplay {
|
||||||
|
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||||
|
return if (isCard) {
|
||||||
|
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
|
balance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
isCard = true,
|
||||||
|
cardBrandIcon = cardBrandIcon(account.accountTypeName),
|
||||||
|
statusLabel = if (isActive) null else account.statusDesc
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
|
balance = listBalance(account)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a display-ready product label for a BML dashboard account or card.
|
||||||
|
*/
|
||||||
|
fun productLabel(raw: String): String {
|
||||||
|
val u = raw.trim().uppercase()
|
||||||
|
return when {
|
||||||
|
u == "SAVINGS ACCOUNT" -> "Savings"
|
||||||
|
u == "CURRENT ACCOUNT" ||
|
||||||
|
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
||||||
|
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
||||||
|
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
||||||
|
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
||||||
|
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
||||||
|
else -> toTitleCase(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
|
||||||
|
fun listBalance(account: MibAccount): String =
|
||||||
|
"${account.currencyName} ${account.currentBalance}"
|
||||||
|
|
||||||
|
fun cardBrandIcon(productName: String): Int = when {
|
||||||
|
productName.contains("AMEX", ignoreCase = true) ||
|
||||||
|
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
|
||||||
|
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
|
||||||
|
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
|
||||||
|
else -> R.drawable.ic_nav_card
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toTitleCase(input: String): String =
|
||||||
|
input.trim().lowercase().split(" ").joinToString(" ") { word ->
|
||||||
|
word.replaceFirstChar { it.uppercaseChar() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
|
|
||||||
|
object BmlHistoryParser {
|
||||||
|
|
||||||
|
fun displayData(account: MibAccount): AccountHistoryDisplay {
|
||||||
|
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
|
||||||
|
return AccountHistoryDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
bankPill = "BML",
|
||||||
|
typeLabel = BmlDashboardParser.productLabel(account.accountTypeName),
|
||||||
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
|
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package sh.sar.basedbank.util.fahipayapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
|
||||||
|
object FahipayAccountParser {
|
||||||
|
|
||||||
|
fun displayData(account: MibAccount) = AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = account.accountTypeName,
|
||||||
|
balance = "${account.currencyName} ${account.availableBalance}"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package sh.sar.basedbank.util.fahipayapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
object FahipayContactParser {
|
||||||
|
|
||||||
|
fun displayData(contact: MibBeneficiary) = ContactDisplay(
|
||||||
|
id = contact.benefNo,
|
||||||
|
name = contact.benefNickName,
|
||||||
|
realName = contact.benefName,
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
categoryId = contact.benefCategoryId,
|
||||||
|
network = TransferNetwork.FAHIPAY,
|
||||||
|
bankColor = contact.bankColor,
|
||||||
|
detail = null, // Fahipay contacts show no detail line
|
||||||
|
imageHash = contact.customerImgHash,
|
||||||
|
profileId = contact.profileId,
|
||||||
|
transferSubtitle = contact.benefAccount,
|
||||||
|
canTransfer = false,
|
||||||
|
canEdit = false,
|
||||||
|
canDelete = false
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package sh.sar.basedbank.util.fahipayapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
|
|
||||||
|
object FahipayHistoryParser {
|
||||||
|
|
||||||
|
fun displayData(account: MibAccount) = AccountHistoryDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
bankPill = "FP",
|
||||||
|
typeLabel = account.accountTypeName,
|
||||||
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
|
blockedBalance = null
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package sh.sar.basedbank.util.mibapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
|
||||||
|
object MibAccountParser {
|
||||||
|
|
||||||
|
fun displayData(account: MibAccount) = AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
|
balance = "${account.currencyName} ${account.availableBalance}"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a display-ready product label for a MIB (Faisanet) account type name.
|
||||||
|
*/
|
||||||
|
fun productLabel(raw: String): String {
|
||||||
|
val u = raw.trim().uppercase()
|
||||||
|
return when {
|
||||||
|
u == "SAVING ACCOUNT" -> "Savings"
|
||||||
|
u == "CURRENT ACCOUNT" -> "Current"
|
||||||
|
else -> raw.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package sh.sar.basedbank.util.mibapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
object MibContactParser {
|
||||||
|
|
||||||
|
fun displayData(contact: MibBeneficiary): ContactDisplay {
|
||||||
|
val network = when (contact.benefType) {
|
||||||
|
"I" -> TransferNetwork.MIB
|
||||||
|
"S" -> TransferNetwork.SWIFT
|
||||||
|
else -> TransferNetwork.LOCAL // "L" and anything else
|
||||||
|
}
|
||||||
|
return ContactDisplay(
|
||||||
|
id = contact.benefNo,
|
||||||
|
name = contact.benefNickName,
|
||||||
|
realName = contact.benefName,
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
categoryId = contact.benefCategoryId,
|
||||||
|
network = network,
|
||||||
|
bankColor = contact.bankColor,
|
||||||
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
imageHash = contact.customerImgHash,
|
||||||
|
profileId = contact.profileId,
|
||||||
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
canTransfer = true,
|
||||||
|
canEdit = true,
|
||||||
|
canDelete = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package sh.sar.basedbank.util.mibapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.mib.MibAccount
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
|
|
||||||
|
object MibHistoryParser {
|
||||||
|
|
||||||
|
fun displayData(account: MibAccount) = AccountHistoryDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
bankPill = null, // MIB has no bank pill
|
||||||
|
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
|
||||||
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
|
blockedBalance = null
|
||||||
|
)
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/americanexpress.xml
Normal file
10
app/src/main/res/drawable/americanexpress.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#016FD0"
|
||||||
|
android:pathData="M16.015 14.378c0-.32-.135-.496-.344-.622-.21-.12-.464-.135-.81-.135h-1.543v2.82h.675v-1.027h.72c.24 0 .39.024.478.125.12.13.104.38.104.55v.35h.66v-.555c-.002-.25-.017-.376-.108-.516-.06-.08-.18-.18-.33-.234l.02-.008c.18-.072.48-.297.48-.747zm-.87.407l-.028-.002c-.09.053-.195.058-.33.058h-.81v-.63h.824c.12 0 .24 0 .33.05.098.048.156.147.15.255 0 .12-.045.215-.134.27zM20.297 15.837H19v.6h1.304c.676 0 1.05-.278 1.05-.884 0-.28-.066-.448-.187-.582-.153-.133-.392-.193-.73-.207l-.376-.015c-.104 0-.18 0-.255-.03-.09-.03-.15-.105-.15-.21 0-.09.017-.166.09-.21.083-.046.177-.066.272-.06h1.23v-.602h-1.35c-.704 0-.958.437-.958.84 0 .9.776.855 1.407.87.104 0 .18.015.225.06.046.03.082.106.082.18 0 .077-.035.15-.08.18-.06.053-.15.07-.277.07zM0 0v10.096L.81 8.22h1.75l.225.464V8.22h2.043l.45 1.02.437-1.013h6.502c.295 0 .56.057.756.236v-.23h1.787v.23c.307-.17.686-.23 1.12-.23h2.606l.24.466v-.466h1.918l.254.465v-.466h1.858v3.948H20.87l-.36-.6v.585h-2.353l-.256-.63h-.583l-.27.614h-1.213c-.48 0-.84-.104-1.08-.24v.24h-2.89v-.884c0-.12-.03-.12-.105-.135h-.105v1.036H6.067v-.48l-.21.48H4.69l-.202-.48v.465H2.235l-.256-.624H1.4l-.256.624H0V24h23.786v-7.108c-.27.135-.613.18-.973.18H21.09v-.255c-.21.165-.57.255-.914.255H14.71v-.9c0-.12-.018-.12-.12-.12h-.075v1.022h-1.8v-1.066c-.298.136-.643.15-.928.136h-.214v.915h-2.18l-.54-.617-.57.6H4.742v-3.93h3.61l.518.602.554-.6h2.412c.28 0 .74.03.942.225v-.24h2.177c.202 0 .644.045.903.225v-.24h3.265v.24c.163-.164.508-.24.803-.24h1.89v.24c.194-.15.464-.24.84-.24h1.176V0H0zM21.156 14.955c.004.005.006.012.01.016.01.01.024.01.032.02l-.042-.035zM23.828 13.082h.065v.555h-.065zM23.865 15.03v-.005c-.03-.025-.046-.048-.075-.07-.15-.153-.39-.215-.764-.225l-.36-.012c-.12 0-.194-.007-.27-.03-.09-.03-.15-.105-.15-.21 0-.09.03-.16.09-.204.076-.045.15-.05.27-.05h1.223v-.588h-1.283c-.69 0-.96.437-.96.84 0 .9.78.855 1.41.87.104 0 .18.015.224.06.046.03.076.106.076.18 0 .07-.034.138-.09.18-.045.056-.136.07-.27.07h-1.288v.605h1.287c.42 0 .734-.118.9-.36h.03c.09-.134.135-.3.135-.523 0-.24-.045-.39-.135-.526zM18.597 14.208v-.583h-2.235V16.458h2.235v-.585h-1.57v-.57h1.533v-.584h-1.532v-.51M13.51 8.787h.685V11.6h-.684zM13.126 9.543l-.007.006c0-.314-.13-.5-.34-.624-.217-.125-.47-.135-.81-.135H10.43v2.82h.674v-1.034h.72c.24 0 .39.03.487.12.122.136.107.378.107.548v.354h.677v-.553c0-.25-.016-.375-.11-.516-.09-.107-.202-.19-.33-.237.172-.07.472-.3.472-.75zm-.855.396h-.015c-.09.054-.195.056-.33.056H11.1v-.623h.825c.12 0 .24.004.33.05.09.04.15.128.15.25s-.047.22-.134.266zM15.92 9.373h.632v-.6h-.644c-.464 0-.804.105-1.02.33-.286.3-.362.69-.362 1.11 0 .512.123.833.36 1.074.232.238.645.31.97.31h.78l.255-.627h1.39l.262.627h1.36v-2.11l1.272 2.11h.95l.002.002V8.786h-.684v1.963l-1.18-1.96h-1.02V11.4L18.11 8.744h-1.004l-.943 2.22h-.3c-.177 0-.362-.03-.468-.134-.125-.15-.186-.36-.186-.662 0-.285.08-.51.194-.63.133-.135.272-.165.516-.165zm1.668-.108l.464 1.118v.002h-.93l.466-1.12zM2.38 10.97l.254.628H4V9.393l.972 2.205h.584l.973-2.202.015 2.202h.69v-2.81H6.118l-.807 1.904-.876-1.905H3.343v2.663L2.205 8.787h-.997L.01 11.597h.72l.26-.626h1.39zm-.688-1.705l.46 1.118-.003.002h-.915l.457-1.12zM11.856 13.62H9.714l-.85.923-.825-.922H5.346v2.82H8l.855-.932.824.93h1.302v-.94h.838c.6 0 1.17-.164 1.17-.945l-.006-.003c0-.78-.598-.93-1.128-.93zM7.67 15.853l-.014-.002H6.02v-.557h1.47v-.574H6.02v-.51H7.7l.733.82-.764.824zm2.642.33l-1.03-1.147 1.03-1.108v2.253zm1.553-1.258h-.885v-.717h.885c.24 0 .42.098.42.344 0 .243-.15.372-.42.372zM9.967 9.373v-.586H7.73V11.6h2.237v-.58H8.4v-.564h1.527V9.88H8.4v-.507" />
|
||||||
|
</vector>
|
||||||
BIN
app/src/main/res/drawable/bml_logo_long.png
Normal file
BIN
app/src/main/res/drawable/bml_logo_long.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
app/src/main/res/drawable/fahipay_logo.png
Normal file
BIN
app/src/main/res/drawable/fahipay_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
66
app/src/main/res/drawable/fahipay_logo_long.xml
Normal file
66
app/src/main/res/drawable/fahipay_logo_long.xml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="190dp"
|
||||||
|
android:height="46dp"
|
||||||
|
android:viewportWidth="1142.31"
|
||||||
|
android:viewportHeight="277.3">
|
||||||
|
|
||||||
|
<!-- P letter mark - top stroke -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M720.28,22.78c35,0,58.9,23.29,58.9,57.39,0,41.86-33,71-81.49,71h-38l-11.56,65.71H606.56L640.7,22.78ZM702.1,113.72c21.75,0,34.69-13.32,34.69-32.16,0-13.88-8.25-21.37-25.32-21.37H675.94l-9.63,53.53Z" />
|
||||||
|
|
||||||
|
<!-- a letter -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M298.85,63.61h40.41L312.31,216.86H271.65l3.72-20.05a67,67,0,0,1-49.55,22.33c-32.67,0-58.74-25.19-58.74-66.45,0-48.12,35.23-91.39,81.64-91.39,20.34,0,37.82,8.31,46.69,22.08Zm-12,67.61c0-21.5-12.88-34.11-31.79-34.11-26.65,0-46.11,24.94-46.11,51.86,0,21.78,12.88,34.36,32.07,34.36C267.91,183.33,286.82,157.84,286.82,131.22Z" />
|
||||||
|
|
||||||
|
<!-- h letter -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M510.16,120.9l-16.9,96H452.58l14.61-83.66c4-23.2-4.29-34.65-22.9-34.65-20.06,0-37,13.46-41.26,38.39l-14,79.92H348.31l37-209.13H426L412.5,84A64.85,64.85,0,0,1,462.32,61.3C494.42,61.3,516.47,84.81,510.16,120.9Z" />
|
||||||
|
|
||||||
|
<!-- i letter -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M527.53,216.86,554.47,63.61h40.66L568.21,216.86ZM559,26.07C559,12.33,570.22,0,584.84,0c11.45,0,20.34,7.46,20.34,19.21,0,13.74-11.75,26.35-26.07,26.35C567.63,45.56,559,37.54,559,26.07Z" />
|
||||||
|
|
||||||
|
<!-- second a letter -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M921.83,63.61h40.4L935.29,216.86H894.63l3.71-20.05a67,67,0,0,1-49.55,22.33c-32.67,0-58.74-25.19-58.74-66.45,0-48.12,35.24-91.39,81.65-91.39,20.34,0,37.82,8.31,46.69,22.08Zm-12,67.61c0-21.5-12.89-34.11-31.8-34.11-26.64,0-46.11,24.94-46.11,51.86,0,21.78,12.89,34.36,32.07,34.36C890.89,183.33,909.8,157.84,909.8,131.22Z" />
|
||||||
|
|
||||||
|
<!-- y letter -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M1097.63,63.61h44.68l-97.39,176.15c-14.62,26.37-29.79,37.54-60.15,37.54H960.41L967,241.49h16.9c13.46,0,20.07-3.71,26.65-16l4.29-7.44L982.18,63.61H1024l18.91,105.13Z" />
|
||||||
|
|
||||||
|
<!-- logo mark - top arc -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M156.34,57.58l8.92-49.85H102.89A78.06,78.06,0,0,0,26.05,72.09L23,89.49h0c16.8-22.76,42.41-31.91,70.71-31.91Z" />
|
||||||
|
|
||||||
|
<!-- logo mark - middle arc -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF15BEA7"
|
||||||
|
android:pathData="M142.8,134l8.92-49.84H89.35a78,78,0,0,0-76.84,64.35L9.22,166.1a.19.19,0,0,0,.35.15C26.38,144,52.12,134,80.11,134Z" />
|
||||||
|
|
||||||
|
<!-- logo mark - bottom arc (gradient) -->
|
||||||
|
<path android:pathData="M9.55,166.25a.19.19,0,0,1-.34-.13c-.07.38-.32,1.73-.54,3L0,216.86H43.25l12.28-65.43c2.3-12.38,12.59-17.31,25.15-17.39h-.59C52.1,134,26.37,144,9.55,166.25Z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="58.15"
|
||||||
|
android:startY="208.94"
|
||||||
|
android:endX="22.66"
|
||||||
|
android:endY="142.19"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0.2" android:color="#FF15B79E" />
|
||||||
|
<item android:offset="0.36" android:color="#FF15A08B" />
|
||||||
|
<item android:offset="0.79" android:color="#FF13655C" />
|
||||||
|
<item android:offset="1.0" android:color="#FF134E4A" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
|
||||||
|
</vector>
|
||||||
14
app/src/main/res/drawable/ic_arrow_right.xml
Normal file
14
app/src/main/res/drawable/ic_arrow_right.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M 3,12 H 21 M 14,5 L 21,12 L 14,19"
|
||||||
|
android:strokeColor="#49454F"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:fillColor="@android:color/transparent" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/ic_check_circle.xml
Normal file
11
app/src/main/res/drawable/ic_check_circle.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zm-2,15l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_drag_handle.xml
Normal file
10
app/src/main/res/drawable/ic_drag_handle.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M9,3h2v2H9V3zm4,0h2v2h-2V3zM9,7h2v2H9V7zm4,0h2v2h-2V7zM9,11h2v2H9v-2zm4,0h2v2h-2v-2zM9,15h2v2H9v-2zm4,0h2v2h-2v-2zM9,19h2v2H9v-2zm4,0h2v2h-2v-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -5,6 +5,6 @@
|
|||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/black"
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
|
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
android:width="24dp" android:height="24dp"
|
android:width="24dp" android:height="24dp"
|
||||||
android:viewportWidth="24" android:viewportHeight="24">
|
android:viewportWidth="24" android:viewportHeight="24">
|
||||||
<path android:fillColor="?attr/colorOnSurfaceVariant"
|
<path android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zm16-4H8C6.9,2 6,2.9 6,4v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4C22,2.9 21.1,2 20,2zm-1,9h-4v4h-2v-4H9V9h4V5h2v4h4v2z"/>
|
android:pathData="M21,18v1c0,1.1 -0.9,2 -2,2H5c-1.11,0 -2,-0.9 -2,-2V4c0,-1.1 0.89,-2 2,-2h14c1.1,0 2,0.9 2,2v1h-9c-1.11,0 -2,0.9 -2,2v10c0,1.1 0.89,2 2,2h9zM12,16h10V8H12v8zM16,13.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z"/>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
10
app/src/main/res/drawable/ic_nav_activities.xml
Normal file
10
app/src/main/res/drawable/ic_nav_activities.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M13,3C8.03,3 4,7.03 4,12H1l3.89,3.89 0.07,0.14L9,12H6c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.25,2.52 0.77,-1.28 -3.52,-2.09V8H12z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_nav_card.xml
Normal file
10
app/src/main/res/drawable/ic_nav_card.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_nav_finances.xml
Normal file
10
app/src/main/res/drawable/ic_nav_finances.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M4,10v7h3v-7H4zM10,10v7h3v-7H10zM2,22h19v-3H2V22zM16,10v7h3v-7H16zM11.5,1L2,6v2h19V6L11.5,1z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_nav_more.xml
Normal file
10
app/src/main/res/drawable/ic_nav_more.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M6,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM18,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||||
|
</vector>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user