51 Commits

Author SHA1 Message Date
89a9731797 new version
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m24s
2026-05-19 00:15:19 +05:00
50badc7d54 support for edge to edge
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 00:13:42 +05:00
d4f86bb738 optimize OTP seed (check password =) remove legacy code
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 23:57:05 +05:00
b35f44f35b optimize bank login screens, add support for otpauth://totp/ URI
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:49:42 +05:00
1a58ce8b54 disable welcome screen page switch with dots
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-18 23:36:31 +05:00
2dd84ec50a countdown now starts after scoll
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:30:21 +05:00
33651ca107 redesign the welcome pages
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 23:12:51 +05:00
ae307e3118 add biometrics disabled hint
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 21:46:34 +05:00
3ab75bff92 fix contact add with multi BML logins
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 05:52:18 +05:00
1753d648bd loading indicator for account info fetch and tranfer
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 05:31:20 +05:00
423b0bf1e1 dark theme on bml recipt and hide bottom nav bar on recipt page 2026-05-18 05:21:46 +05:00
d713047970 keep bottom bar behind keyboard
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 05:12:49 +05:00
d59a6fad82 new feature: resume last activity after unlocking instead of goind to dashboard everytime
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 05:09:42 +05:00
8e47101401 refactor: extract BML/MIB product label parsing into dedicated parser utilities
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 04:53:40 +05:00
9431a90cd0 add card icons
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 04:35:20 +05:00
3a10f36c39 fix prod build
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 00:43:05 +05:00
27c428d1f6 fix prod build
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
Build and Release APK / build (push) Successful in 4m18s
2026-05-18 00:39:20 +05:00
7b4f650f4e auto select all tab in contact picker on search bar selection
Some checks failed
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Failing after 4m4s
2026-05-18 00:32:55 +05:00
00e109562b add support for multiple BML accounts, and BML credit cards
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-18 00:29:52 +05:00
cd4b3fef8b redeisgn accounts page
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 22:34:15 +05:00
389344a192 normaize account input
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 21:45:49 +05:00
04af4e1bbd add biometrics to confirm digalogbox
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-17 21:39:42 +05:00
6197152f6e fix ui glitch, trasnfer button to take you to transfer tab in bottom bar 2026-05-17 21:09:43 +05:00
ecbbb3ed6b categorize settings to unclutter it.. might need to revert this later
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-17 20:09:09 +05:00
b26f9deba7 new accounts icon, new page for more
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-17 19:40:51 +05:00
2b710f00c6 add support for bottom nav bar
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-17 19:29:44 +05:00
9bbb024d69 add proper menu icons
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 19:18:29 +05:00
a60a18ee7f Tool tip for pick contact and scan QR
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 19:13:52 +05:00
7782ac57f1 show notice when camera permission rejected
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 19:11:26 +05:00
36ab100aa7 optimize contacts page and contact picker
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 19:01:53 +05:00
83499aed35 add fahipay contact fetch docs
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-17 16:40:51 +05:00
9cc92aff02 add LICENSE
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-17 00:48:13 +05:00
153c98862e add readme
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 00:47:48 +05:00
b5bf950271 update security audit 2026-05-17 00:47:37 +05:00
bb2c4f0158 add docs for ported numbers
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 00:32:34 +05:00
ccc9e11d55 add support to detect dhiraagu and ooredoo packages
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-17 00:25:48 +05:00
93405aade2 add support for fahipay contacts 2026-05-16 22:26:16 +05:00
fd531066cd change text to national id instead of fahipay id
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-16 21:59:52 +05:00
ffe50467e7 add support for fahipay transfer history
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-16 21:56:00 +05:00
d4104e2ed2 add support for fahipay images
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-16 21:42:04 +05:00
7864655a82 add support for fahipay login and view history
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-16 21:31:34 +05:00
99a32dc9ed security report
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-16 20:09:48 +05:00
4c19551ee7 remove http logging that was added for debugging
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-16 20:00:01 +05:00
0a6a199e9e optimize contact picker for usd contacts and transfer page
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-16 14:50:07 +05:00
480f4e42d7 add warning for USD to MVR trnasfers
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-16 14:19:46 +05:00
92bbe383b8 optimize bml and mib recipt to be 1:1
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-16 14:11:42 +05:00
4b6c5e5d8b optimize account search in tranfer page
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-15 19:25:26 +05:00
253e5400d8 redesign contacts page
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-15 19:12:40 +05:00
b9be7cd1a3 add flagsecure and rearrange settings listing
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-15 18:44:58 +05:00
fc031f1f2a security: encrypt credentials, caches, and harden lock screen
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-15 18:35:14 +05:00
106004421e update ci to fetch reponame as apk name
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 13s
Build and Release APK / build (push) Successful in 4m3s
2026-05-15 17:20:05 +05:00
130 changed files with 7532 additions and 1540 deletions

View File

@@ -8,4 +8,4 @@ services:
volumes:
- ./release:/release
- ../../:/source
- /root/.cache/cache-runners/gradle:/root/.gradle
# - /root/.cache/cache-runners/gradle:/root/.gradle

View File

@@ -22,6 +22,13 @@ jobs:
working-directory: .build/release
run: docker compose run --rm release
- name: Rename APK
run: |
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
TAG="${{ gitea.ref_name }}"
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
- name: Extract release notes
run: |
echo "No release notes" > release_notes.md
@@ -43,10 +50,12 @@ jobs:
GITEA_REPOSITORY: ${{ gitea.repository }}
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
run: |
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
TAG="${{ gitea.ref_name }}"
TITLE="ISODroid ${{ gitea.ref_name }}"
TITLE="${APP_NAME} ${TAG}"
NOTES_FILE="release_notes.md"
ASSET_PATH=".build/release/release/ISODroid-${{ gitea.ref_name }}.apk"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
API_URL="${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases"

2
.gitignore vendored
View File

@@ -15,4 +15,6 @@
local.properties
docs/mibapi/tmp
docs/bmlapi/tmp
docs/fahipayapi/tmp
tmp
app/key.jks

View File

@@ -3,15 +3,28 @@
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-04-17T17:14:16.980728222Z">
<option name="selectionMode" value="DIALOG" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
</handle>
</Target>
</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>
</selectionStates>
</component>

232
LICENSE Normal file
View 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
View 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.
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
![Android 8.0+](https://img.shields.io/badge/Android-8.0+-3DDC84?logo=android&logoColor=white)
![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white)
![Maintained](https://img.shields.io/badge/Maintained-yes-green.svg)
## 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.

View File

@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = 2
versionName = "1.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -64,9 +64,11 @@ dependencies {
// RecyclerView for accounts list
implementation("androidx.recyclerview:recyclerview:1.3.2")
// CircularProgressDrawable for spinning search icons
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// OkHttp for API calls
implementation("com.squareup.okhttp3:okhttp:4.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")

View File

@@ -48,7 +48,8 @@
<activity
android:name=".ui.home.HomeActivity"
android:exported="false" />
android:exported="false"
android:windowSoftInputMode="adjustPan" />
<activity
android:name=".ui.home.QrScannerActivity"

View File

@@ -5,10 +5,12 @@ import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.sync.Mutex
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.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() {
@@ -17,14 +19,24 @@ class BasedBankApp : Application() {
var fullName: String = ""
var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList()
var bmlSession: BmlSession? = null
/** Active BML sessions keyed by loginId (= BML username). */
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null
var fahipayAccounts: List<MibAccount> = emptyList()
/** Returns the BML session for the given account (matched via loginTag). */
fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")]
/** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex()
val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow ->
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSession = session
mibProfiles = profiles

View File

@@ -5,14 +5,24 @@ import android.os.Bundle
import android.util.Base64
import android.view.View
import android.widget.LinearLayout
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding
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() {
@@ -22,18 +32,40 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String
private lateinit var storedHash: String
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?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater)
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)
method = prefs.getString("security_method", "pin") ?: "pin"
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
salt = stored.first
storedHash = stored.second
if (method == "pin") {
binding.viewPin.visibility = View.VISIBLE
buildNumpad()
@@ -48,6 +80,12 @@ class LockActivity : AppCompatActivity() {
}
if (biometricsEnabled) triggerBiometric()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
moveTaskToBack(true)
}
})
}
private fun buildNumpad() {
@@ -92,6 +130,7 @@ class LockActivity : AppCompatActivity() {
}
private fun handleKey(key: String) {
if (isVerifying) return
when (key) {
"" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
"" -> if (pinDigits.size >= 4) verifyPin()
@@ -105,13 +144,20 @@ class LockActivity : AppCompatActivity() {
}
private fun verifyPin() {
if (checkAndShowLockout()) return
val entered = pinDigits.joinToString("")
if (verify(entered)) {
proceed()
} else {
binding.tvLockPinDots.text = getString(R.string.unlock_failed)
pinDigits.clear()
binding.root.postDelayed({ updateDots() }, 1200)
pinDigits.clear()
updateDots()
isVerifying = true
lifecycleScope.launch {
val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false
if (ok) {
resetFailures()
proceed()
} else {
showFailure()
}
}
}
@@ -121,18 +167,59 @@ class LockActivity : AppCompatActivity() {
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
return
}
if (verify(pattern.joinToString(""))) {
proceed()
} else {
if (checkAndShowLockout()) {
binding.lockPatternView.showError()
binding.tvPatternHint.text = getString(R.string.unlock_failed)
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
return
}
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 {
val hash = sha256(salt + input)
return hash == storedHash
if (storedHash.isBlank()) return false
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
return pbkdf2(input, saltBytes) == storedHash
}
private fun triggerBiometric() {
@@ -145,6 +232,7 @@ class LockActivity : AppCompatActivity() {
ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
resetFailures()
proceed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
@@ -162,15 +250,46 @@ class LockActivity : AppCompatActivity() {
}
private fun proceed() {
startActivity(Intent(this, HomeActivity::class.java))
finish()
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
finish()
} else {
startActivity(Intent(this, HomeActivity::class.java))
finish()
}
}
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
// ── Brute-force tracking ──────────────────────────────────────────────────
override fun onBackPressed() {
// Lock screen cannot be dismissed
moveTaskToBack(true)
private fun incrementFailures(): Int {
val fails = lockPrefs.getInt("fail_count", 0) + 1
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()
}
}
}

View File

@@ -16,7 +16,7 @@ class MainActivity : AppCompatActivity() {
val onboardingDone = prefs.getBoolean("onboarding_done", false)
val securitySet = prefs.getString("security_method", null) != null
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials()
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
val target = when {
!onboardingDone -> OnboardingActivity::class.java

View File

@@ -69,8 +69,11 @@ class BmlLoginFlow {
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
// Step 2: POST credentials
val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}"""
.toRequestBody("application/json".toMediaType())
val loginBody = JSONObject().apply {
put("username", username)
put("password", password)
put("code", "")
}.toString().toRequestBody("application/json".toMediaType())
val loginResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
.header("X-XSRF-TOKEN", xsrf)
@@ -89,8 +92,10 @@ class BmlLoginFlow {
// Step 4: POST OTP
val otp = Totp.generate(otpSeed)
val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}"""
.toRequestBody("application/json".toMediaType())
val twoFaBody = JSONObject().apply {
put("code", otp)
put("channel", "authenticator")
}.toString().toRequestBody("application/json".toMediaType())
val twoFaResp = client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
.header("X-XSRF-TOKEN", xsrf2)
@@ -161,17 +166,17 @@ class BmlLoginFlow {
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session)
val accounts = fetchAccounts(session, "bml_$username")
return Pair(session, accounts)
}
fun fetchAccounts(session: BmlSession): List<MibAccount> {
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseDashboard(json ?: return emptyList(), "bml_${session.deviceId}")
return parseDashboard(json ?: return emptyList(), loginTag)
}
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
@@ -319,11 +324,11 @@ class BmlLoginFlow {
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 json = resp.body?.string() ?: return emptyList()
resp.close()
return parseContacts(json)
return parseContacts(json, loginId)
}
/**
@@ -411,7 +416,8 @@ class BmlLoginFlow {
BmlTransferResult(
success = true,
reference = payload?.optString("reference") ?: "",
timestamp = payload?.optString("timestamp") ?: ""
timestamp = payload?.optString("timestamp") ?: "",
message = json.optString("message")
)
}
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
@@ -628,29 +634,28 @@ class BmlLoginFlow {
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue // debit cards and other hidden cards — skip
val isPrepaid = item.optBoolean("prepaid_card", false)
if (isPrepaid) {
val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
prepaidCards.add(MibAccount(
profileName = "Personal",
profileType = "BML_PREPAID",
accountNumber = accountNumber,
accountBriefName = product,
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0),
blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
internalId = internalId
))
} else {
// Linked debit cards have no independent balance or account link — skip
}
val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount(
profileName = "Personal",
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(current),
blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
internalId = internalId
))
}
}
@@ -688,7 +693,7 @@ class BmlLoginFlow {
} catch (_: Exception) { emptyList() }
}
private fun parseContacts(json: String): List<MibBeneficiary> {
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
@@ -709,7 +714,8 @@ class BmlLoginFlow {
benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null,
benefCategoryId = "BML"
benefCategoryId = "BML",
profileId = loginId
))
}
return result
@@ -740,6 +746,4 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}

View File

@@ -20,6 +20,7 @@ data class BmlTransferResult(
val success: Boolean,
val reference: String = "",
val timestamp: String = "",
val message: String = "",
val errorMessage: String = ""
)

View File

@@ -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) }
}
}

View File

@@ -0,0 +1,348 @@
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(
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) }
}
}
}

View File

@@ -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>
)

View File

@@ -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 }
}
}

View File

@@ -1,19 +1,20 @@
package sh.sar.basedbank.api.mib
import okhttp3.FormBody
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.random.Random
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/"
/** The active session after a successful login, usable for subsequent WebView requests. */
@@ -31,7 +32,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
@Volatile private var storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null
private var inRelogin = false
private val inRelogin = AtomicBoolean(false)
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
@@ -61,11 +62,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
storedPasswordHash = passwordHash
storedOtpSeed = otpSeed
val appId = getOrCreateAppId()
val key1 = prefs.getString("mib_key1_$username", null)
val key2 = prefs.getString("mib_key2_$username", null)
val keys = credentialStore.loadMibKeys()
return if (key1 != null && key2 != null) {
regularLogin(username, passwordHash, appId, key1, key2)
return if (keys != null) {
regularLogin(username, passwordHash, appId, keys.first, keys.second)
} else {
firstTimeRegistration(username, passwordHash, otpSeed, appId)
}
@@ -106,7 +106,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2")
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
credentialStore.saveMibKeys(key1, key2)
return regularLogin(username, passwordHash, appId, key1, key2)
}
@@ -189,7 +189,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
val response = try {
sendRequest(session, data, sfunc)
} catch (e: SessionExpiredException) {
if (inRelogin) throw e
if (inRelogin.get()) throw e
"" // fall through to recovery below
}
@@ -198,22 +198,27 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
(response.trimStart().startsWith("{") &&
JSONObject(response).optString("reasonCode") == "505")
if (isExpired && !inRelogin) {
val u = storedUsername ?: throw SessionExpiredException()
val ph = storedPasswordHash ?: throw SessionExpiredException()
val os = storedOtpSeed ?: throw SessionExpiredException()
inRelogin = true
try { login(u, ph, os) } finally { inRelogin = false }
val newSession = lastSession ?: throw SessionExpiredException()
onSessionRefreshed?.invoke(newSession, lastProfiles)
// Refresh nonce/xxid in the payload for the retry
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
data.put("appId", newSession.appId)
data.put("sodium", MibNonce.randomSodium())
data.put("xxid", newSession.xxid)
val retryResponse = sendRequest(newSession, data, sfunc)
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
if (isExpired && inRelogin.compareAndSet(false, true)) {
try {
val u = storedUsername ?: throw SessionExpiredException()
val ph = storedPasswordHash ?: throw SessionExpiredException()
val os = storedOtpSeed ?: throw SessionExpiredException()
login(u, ph, os)
val newSession = lastSession ?: throw SessionExpiredException()
onSessionRefreshed?.invoke(newSession, lastProfiles)
// Refresh nonce/xxid in the payload for the retry
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
data.put("appId", newSession.appId)
data.put("sodium", MibNonce.randomSodium())
data.put("xxid", newSession.xxid)
val retryResponse = sendRequest(newSession, data, sfunc)
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
} finally {
inRelogin.set(false)
}
} else if (isExpired) {
throw SessionExpiredException()
}
if (response.trimStart().startsWith("{")) return JSONObject(response)
@@ -384,11 +389,11 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String {
var id = prefs.getString("mib_app_id", null)
var id = credentialStore.loadMibAppId()
if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
prefs.edit().putString("mib_app_id", id).apply()
credentialStore.saveMibAppId(id)
}
return id
}

View File

@@ -82,7 +82,8 @@ data class Transaction(
val reference: String?,
val accountNumber: 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(

View File

@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.mib
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.util.CacheEncryption
import java.io.File
object TransactionCache {
@@ -21,8 +22,9 @@ object TransactionCache {
put("accountNumber", t.accountNumber)
put("accountDisplayName", t.accountDisplayName)
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) {}
}
@@ -32,7 +34,7 @@ object TransactionCache {
}
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 ->
val o = arr.getJSONObject(i)
Transaction(
@@ -45,7 +47,8 @@ object TransactionCache {
reference = o.optString("reference").takeIf { it.isNotBlank() },
accountNumber = o.optString("accountNumber"),
accountDisplayName = o.optString("accountDisplayName"),
source = o.optString("source")
source = o.optString("source"),
iconUrl = o.optString("iconUrl").takeIf { it.isNotBlank() }
)
}
} catch (_: Exception) { emptyList() }

View File

@@ -31,7 +31,10 @@ class AccountHistoryAdapter(
private val displayItems = mutableListOf<Item>()
private var lastInsertedDateKey = ""
private val imageCache = mutableMapOf<String, Bitmap>()
private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((MibAccount) -> Unit)? = null
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
@@ -41,6 +44,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
var showLoadingFooter: Boolean
get() = _showLoadingFooter
@@ -139,7 +150,11 @@ class AccountHistoryAdapter(
fun bind(acc: MibAccount) {
b.tvHeaderAccountName.text = acc.accountBriefName
b.tvHeaderAccountNumber.text = acc.accountNumber
b.tvHeaderPillBank.text = if (acc.profileType.startsWith("BML")) "BML" else "MIB"
b.tvHeaderPillBank.text = when {
acc.profileType.startsWith("BML") -> "BML"
acc.profileType == "FAHIPAY" -> "FP"
else -> null
}
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
@@ -150,6 +165,7 @@ class AccountHistoryAdapter(
} else {
b.llHeaderBlocked.visibility = View.GONE
}
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
}
private fun friendlyType(raw: String): String {
@@ -178,7 +194,10 @@ class AccountHistoryAdapter(
val name = trx.counterpartyName ?: trx.description
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) {
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
rd.isCircular = true
@@ -192,7 +211,8 @@ class AccountHistoryAdapter(
b.fvAvatar.background = circle
b.tvInitial.visibility = View.VISIBLE
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

View File

@@ -1,6 +1,8 @@
package sh.sar.basedbank.ui.home
import android.graphics.BitmapFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
@@ -18,7 +20,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
@@ -26,6 +30,7 @@ import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@@ -43,6 +48,7 @@ class AccountHistoryFragment : Fragment() {
private var searchQuery = ""
private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = mutableSetOf<String>()
// Pagination state
private var mibNextStart = 1
@@ -50,6 +56,8 @@ class AccountHistoryFragment : Fragment() {
private var bmlNextPage = 1
private var bmlTotalPages = -1
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
private var fahipayNextStart = 0
private var fahipayTotal = -1
private var isLoading = false
private val pageSize = 10
@@ -74,6 +82,10 @@ class AccountHistoryFragment : Fragment() {
adapter = AccountHistoryAdapter(account)
adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
@@ -121,10 +133,12 @@ class AccountHistoryFragment : Fragment() {
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 isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private fun isFahipay() = account.profileType == "FAHIPAY"
private fun hasMore(): Boolean = when {
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
@@ -143,6 +157,20 @@ class AccountHistoryFragment : Fragment() {
lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
when {
isFahipay() -> {
val session = app.fahipaySession ?: return@withContext 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
list
}
isMib() -> {
val session = app.mibSession ?: return@withContext emptyList()
app.mibMutex.withLock {
@@ -161,7 +189,7 @@ class AccountHistoryFragment : Fragment() {
}
}
isBmlCard() -> {
val session = app.bmlSession ?: return@withContext emptyList()
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
@@ -175,7 +203,7 @@ class AccountHistoryFragment : Fragment() {
)
}
else -> {
val session = app.bmlSession ?: return@withContext emptyList()
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
@@ -247,6 +275,29 @@ 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() {
(activity as? HomeActivity)?.setRefreshing(false)
super.onDestroyView()

View File

@@ -3,17 +3,18 @@ package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.BmlDashboardParser
import sh.sar.basedbank.util.MibAccountParser
class AccountsAdapter(
accounts: List<MibAccount>,
@@ -21,7 +22,7 @@ class AccountsAdapter(
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
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 Card(val account: MibAccount) : Item()
}
@@ -35,19 +36,40 @@ class AccountsAdapter(
}
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" }
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
if (regular.isNotEmpty()) {
add(Item.SectionTitle("Accounts", ""))
regular.forEach { add(Item.Account(it)) }
// Group non-prepaid accounts by their derived section title, preserving order
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
for (acc in nonPrepaid) {
val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc)
}
for ((title, group) in groups) {
add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) }
}
if (prepaid.isNotEmpty()) {
add(Item.SectionTitle("Cards", "BML"))
add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) }
}
}
private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
}
val bank = when {
account.profileType.startsWith("BML") -> "Bank of Maldives"
account.profileType == "FAHIPAY" -> "Fahipay"
else -> "Maldives Islamic Bank"
}
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
}
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.SectionTitle -> TYPE_HEADER
is Item.Account -> TYPE_ACCOUNT
@@ -57,7 +79,7 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
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))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
}
@@ -73,16 +95,10 @@ class AccountsAdapter(
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) {
fun bind(item: Item.SectionTitle) {
binding.tvProfileName.text = item.label
if (item.chip.isNotEmpty()) {
binding.tvProfileType.text = item.chip
binding.tvProfileType.visibility = View.VISIBLE
} else {
binding.tvProfileType.visibility = View.GONE
}
binding.tvDateHeader.text = item.label
}
}
@@ -91,14 +107,12 @@ class AccountsAdapter(
fun bind(account: MibAccount) {
binding.tvAccountName.text = account.accountBriefName
binding.tvAccountNumber.text = account.accountNumber
binding.tvPillBank.text = if (account.profileType.startsWith("BML")) "BML" else "MIB"
binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
binding.tvPillProfile.text = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
}
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
val label = if (account.profileType.startsWith("BML"))
BmlDashboardParser.productLabel(account.accountTypeName)
else
MibAccountParser.productLabel(account.accountTypeName)
binding.tvPillType.text = label
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber)
@@ -110,15 +124,10 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) {
val brand = cardBrand(account.accountTypeName)
binding.tvCardBrand.text = brand.label
binding.tvCardBrand.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 100f
setColor(Color.parseColor(brand.color))
}
binding.tvCardName.text = account.accountBriefName
binding.tvCardNumber.text = account.accountNumber
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName))
binding.tvCardName.text = account.accountBriefName
binding.tvCardNumber.text = account.accountNumber
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName)
binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
@@ -146,29 +155,12 @@ class AccountsAdapter(
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 {
private fun cardBrandIcon(productName: String): Int = 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")
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
}
}
}

View File

@@ -12,12 +12,18 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -43,7 +49,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private data class DestinationOption(
val label: String,
val isBml: Boolean,
val mibProfile: MibProfile? = null
val mibProfile: MibProfile? = null,
val bmlLoginId: String? = null,
val subtitle: String = ""
)
private var destinations: List<DestinationOption> = emptyList()
@@ -86,18 +94,44 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
for (profile in app.mibProfiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
}
if (app.bmlSession != null) {
list.add(DestinationOption("BML · Personal", isBml = true))
val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) {
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
}
return list
}
private fun setupDestinationDropdown() {
val labels = destinations.map { it.label }
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels)
val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
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.setOnItemClickListener { _, _, position, _ ->
selectedDest = destinations[position]
binding.actvDestination.setText(destinations[position].label, false)
clearLookupResult()
updateMibOnlyVisibility()
binding.btnSave.isEnabled = false
@@ -129,6 +163,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() {
binding.tilAccount.setEndIconOnClickListener { performLookup() }
}
@@ -167,7 +217,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
return
}
binding.tilAccount.isEnabled = false
startLookupLoading()
binding.tilDestination.isEnabled = false
binding.btnSave.isEnabled = false
@@ -175,7 +225,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val result = withContext(Dispatchers.IO) {
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
}
binding.tilAccount.isEnabled = true
stopLookupLoading()
binding.tilDestination.isEnabled = true
if (result != null) {
showLookupResult(result, input)
@@ -186,7 +236,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
}
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()
// 1) Try BML validate
@@ -236,7 +287,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (mibResult != null) return mibResult
// 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 }
}
@@ -341,6 +392,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.tilAlias.error = null
binding.btnSave.isEnabled = false
binding.btnSave.text = "Saving..."
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) {
@@ -352,13 +404,15 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
dismiss()
} else {
binding.btnSave.isEnabled = true
binding.btnSave.text = "Save"
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
}
}
}
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 bmlFlow = BmlLoginFlow()
val account = lookup.account
@@ -425,12 +479,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try {
if (dest.isBml) {
val bmlSess = app.bmlSession ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess)
val loginId = dest.bmlLoginId ?: return@launch
val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged)
ContactsCache.saveBml(requireContext(), fresh)
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else {
val profile = dest.mibProfile ?: return@launch
val mibSess = app.mibSession ?: return@launch

View File

@@ -14,6 +14,7 @@ import sh.sar.basedbank.databinding.ItemPickerRowBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
class ContactPickerAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onItemClick: (accountNumber: String, label: String) -> Unit,
private val onSameAsFrom: () -> Unit,
private val onImageNeeded: ((hash: String) -> Unit)? = null,
@@ -35,7 +36,6 @@ class ContactPickerAdapter(
}
private var items: List<PickerItem> = emptyList()
private val imageCache = mutableMapOf<String, Bitmap>()
fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap

View File

@@ -33,6 +33,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private val viewModel: HomeViewModel by activityViewModels()
private val pendingHashes = mutableSetOf<String>()
private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
@@ -48,6 +49,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
ContactPickerAdapter(
imageCache = sharedImageCache,
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
onSameAsFrom = {},
onImageNeeded = { hash -> fetchImage(hash) },
@@ -120,6 +122,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
attachMediator(initialPages)
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 ->
val pages = buildList {
@@ -137,6 +145,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.loadAllContacts()
}
private fun attachMediator(pages: List<TabDef>) {
@@ -153,17 +163,19 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
when {
account?.profileType == "BML_PREPAID" -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
account != null -> {
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E")
}
contact != null && !contact.transferCyDesc.equals("MVR", ignoreCase = true) -> {
bundle.putBoolean(KEY_SKIP_LOOKUP, true)
contact != null -> {
bundle.putString(KEY_SUBTITLE, "${contact.benefBankName} · ${contact.benefAccount}")
bundle.putString(KEY_COLOR, contact.bankColor)
contact.customerImgHash?.let { bundle.putString(KEY_IMAGE_HASH, it) }
}
else -> {
bundle.putString(KEY_SUBTITLE, accountNumber)
bundle.putString(KEY_COLOR, "#607D8B")
}
}
setFragmentResult(REQUEST_KEY, bundle)
dismiss()
@@ -197,11 +209,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: ""
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) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" }
val cards = 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" || it.profileType == "BML_CREDIT" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -306,7 +318,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
const val REQUEST_KEY = "contact_picker"
const val KEY_ACCOUNT_NUMBER = "accountNumber"
const val KEY_LABEL = "label"
const val KEY_SKIP_LOOKUP = "skipLookup"
const val KEY_SUBTITLE = "subtitle"
const val KEY_COLOR = "color"
const val KEY_IMAGE_HASH = "imageHash"

View File

@@ -8,7 +8,6 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
@@ -17,6 +16,7 @@ import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding
class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit
@@ -24,15 +24,12 @@ class ContactsAdapter(
private var allContacts: List<MibBeneficiary> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList()
private val imageCache = mutableMapOf<String, Bitmap>()
private val expandedPositions = mutableSetOf<Int>()
private var activeCategoryId: String? = null
private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) {
allContacts = contacts
expandedPositions.clear()
applyFilter()
}
@@ -46,7 +43,6 @@ class ContactsAdapter(
fun setFilter(categoryId: String?, query: String) {
activeCategoryId = categoryId
searchQuery = query
expandedPositions.clear()
applyFilter()
}
@@ -64,7 +60,30 @@ class ContactsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
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].benefAccount
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) {
@@ -72,34 +91,7 @@ class ContactsAdapter(
val cachedImage = contact.customerImgHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null }
}
holder.bind(contact, cachedImage, position in expandedPositions)
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)
}
holder.bind(contact, cachedImage)
}
override fun getItemCount() = displayed.size
@@ -107,10 +99,15 @@ class ContactsAdapter(
inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?, expanded: Boolean) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
val isFahipay = contact.benefType == "FAHIPAY"
binding.tvContactName.text = contact.benefNickName
binding.tvContactBank.text = contact.benefBankName
binding.tvContactAccount.text = "${contact.benefAccount} · ${contact.transferCyDesc}"
binding.tvContactAccount.text = contact.benefAccount
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}"
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo)
@@ -119,12 +116,6 @@ class ContactsAdapter(
makeInitialsBitmap(contact.benefNickName, 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 {

View File

@@ -36,6 +36,7 @@ class ContactsFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
private val pendingHashes = mutableSetOf<String>()
private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession
@@ -52,6 +53,7 @@ class ContactsFragment : Fragment() {
private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter(
imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) }
@@ -115,6 +117,8 @@ class ContactsFragment : Fragment() {
AddContactSheetFragment().show(childFragmentManager, "add_contact")
}
(activity as? HomeActivity)?.loadAllContacts()
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
rebuildPager(cats)
}
@@ -154,7 +158,7 @@ class ContactsFragment : Fragment() {
colorHex = contact.bankColor,
imageHash = contact.customerImgHash
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
}
private fun confirmDelete(contact: MibBeneficiary) {
@@ -181,7 +185,7 @@ class ContactsFragment : Fragment() {
}
private fun deleteBml(contact: MibBeneficiary): Boolean {
val sess = app.bmlSession ?: return false
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
@@ -201,7 +205,11 @@ class ContactsFragment : Fragment() {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
viewModel.contacts.value = updated
if (contact.benefCategoryId == "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 {
ContactsCache.save(
requireContext(),

View File

@@ -31,7 +31,7 @@ class DashboardFragment : Fragment() {
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
binding.btnTransfer.setOnClickListener {
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment())
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer)
}
binding.btnPayMvQr.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()

View File

@@ -1,6 +1,7 @@
package sh.sar.basedbank.ui.home
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Handler
@@ -14,6 +15,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
@@ -31,6 +33,8 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.bml.BmlLoginFlow
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.MibLoginFlow
import sh.sar.basedbank.databinding.ActivityHomeBinding
@@ -52,94 +56,111 @@ class HomeActivity : AppCompatActivity() {
private lateinit var binding: ActivityHomeBinding
private val viewModel: HomeViewModel by viewModels()
private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
private val autolockHandler = Handler(Looper.getMainLooper())
private var warningDialog: AlertDialog? = null
private var countdownTimer: CountDownTimer? = null
private var pauseTime = 0L
private val warningRunnable = Runnable { showAutolockWarning() }
private var isLocked = false
private val autolockRunnable = Runnable {
countdownTimer?.cancel(); countdownTimer = null
warningDialog?.dismiss(); warningDialog = null
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
.getString("security_method", null) != null
if (securitySet) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
finish()
}
if (securitySet) lock()
}
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?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater)
if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
}
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)
val toggle = ActionBarDrawerToggle(
toggle = ActionBarDrawerToggle(
this, binding.drawerLayout, binding.toolbar,
R.string.nav_open_drawer, R.string.nav_close_drawer
)
binding.drawerLayout.addDrawerListener(toggle)
toggle.syncState()
binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
when (item.itemId) {
R.id.nav_dashboard -> { show(DashboardFragment()); true }
R.id.nav_accounts -> { show(AccountsFragment()); true }
R.id.nav_contacts -> { show(ContactsFragment()); true }
R.id.nav_transfer -> { show(TransferFragment()); true }
R.id.nav_more -> { show(MoreFragment()); true }
else -> false
}
}
applyNavMode()
binding.navigationView.setNavigationItemSelectedListener { item ->
binding.drawerLayout.closeDrawers()
when (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()
}
navigateTo(item.itemId)
true
}
// Load data
val app = application as BasedBankApp
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty()) {
if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background
val mibAccounts = app.accounts.filter { it.profileType != "BML" }
val merged = mibAccounts + app.bmlAccounts
val mibAccounts = app.accounts.filter { !it.profileType.startsWith("BML") && it.profileType != "FAHIPAY" }
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
viewModel.accounts.value = merged
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts)
if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
}
if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts)
val cachedFinancing = FinancingCache.load(this)
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)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
refreshFinancing(app.mibSession, app.mibProfiles)
refreshContacts(app.mibSession, app.mibProfiles)
if (app.bmlSession != null) {
refreshBmlContacts(app)
refreshBmlLimits(app.bmlSession!!)
}
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
} else {
// Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this)
val cachedMib = AccountCache.load(this)
val cachedBml = AccountCache.loadBml(this)
val merged = cachedMib + cachedBml
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
val cachedFahipay = AccountCache.loadFahipay(this)
val merged = cachedMib + cachedBml + cachedFahipay
if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedFinancing = FinancingCache.load(this)
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)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
val store = CredentialStore(this)
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store)
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
}
// Show dashboard on first create
@@ -181,6 +202,50 @@ class HomeActivity : AppCompatActivity() {
.commit()
}
fun applyNavMode() {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).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
} else {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true
toggle.syncState()
binding.bottomNavigation.visibility = View.GONE
}
}
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_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)
val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more)
if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = itemId
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) {
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
}
@@ -194,11 +259,30 @@ class HomeActivity : AppCompatActivity() {
override fun 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()
}
override fun onPause() {
super.onPause()
pauseTime = System.currentTimeMillis()
autolockHandler.removeCallbacks(autolockRunnable)
autolockHandler.removeCallbacks(warningRunnable)
countdownTimer?.cancel(); countdownTimer = null
@@ -260,8 +344,7 @@ class HomeActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
finish()
lock()
return true
}
return super.onOptionsItemSelected(item)
@@ -271,8 +354,9 @@ class HomeActivity : AppCompatActivity() {
fun relogin() {
val store = CredentialStore(this)
val hasMib = store.hasMibCredentials()
val hasBml = store.hasBmlCredentials()
if (!hasMib && !hasBml) {
val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials()
if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) {
startActivity(Intent(this, LoginActivity::class.java))
finish()
return
@@ -280,28 +364,31 @@ class HomeActivity : AppCompatActivity() {
// Immediately drop accounts for logged-out banks from the displayed list
val current = viewModel.accounts.value ?: emptyList()
viewModel.accounts.value = current.filter { acc ->
if (!hasMib && !acc.profileType.startsWith("BML")) return@filter false
if (!hasBml && acc.profileType.startsWith("BML")) return@filter false
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false
if (acc.profileType.startsWith("BML")) {
val loginId = acc.loginTag.removePrefix("bml_")
return@filter loginId in bmlLoginIds
}
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
true
}
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store)
autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
}
private fun autoRefresh(
mibCreds: CredentialStore.MibCredentials?,
bmlCreds: CredentialStore.BmlCredentials?,
fahipayCreds: CredentialStore.FahipayCredentials?,
store: CredentialStore
) {
if (mibCreds == null && bmlCreds == null) return
val bmlLoginIds = store.getBmlLoginIds()
if (mibCreds == null && bmlLoginIds.isEmpty() && fahipayCreds == null) return
binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch {
// MIB and BML login run in parallel
val mibJob = mibCreds?.let {
async(Dispatchers.IO) {
try {
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
val app = application as BasedBankApp
app.accounts = accounts
@@ -313,56 +400,99 @@ class HomeActivity : AppCompatActivity() {
}
}
val bmlJob = bmlCreds?.let {
async(Dispatchers.IO) {
// One async job per BML login, all run in parallel
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val bmlFlow = BmlLoginFlow()
val savedToken = store.loadBmlSession()
val loginTag = "bml_$loginId"
val savedToken = store.loadBmlSession(loginId)
// Try cached token first
if (savedToken != null) {
try {
val session = BmlSession(savedToken.first, savedToken.second)
val accounts = bmlFlow.fetchAccounts(session)
val accounts = bmlFlow.fetchAccounts(session, loginTag)
val app = application as BasedBankApp
app.bmlSession = session
app.bmlAccounts = accounts
AccountCache.saveBml(this@HomeActivity, accounts)
app.bmlSessions[loginId] = session
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
return@async Pair(session, accounts)
} catch (_: AuthExpiredException) {
// Token expired — fall through to full login
} catch (_: Exception) {
// Network or other error — fall through to full login
}
}
// Full login (token missing or expired)
try {
val (session, accounts) = bmlFlow.login(it.username, it.password, it.otpSeed)
store.saveBmlSession(session.accessToken, session.deviceId)
val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
val app = application as BasedBankApp
app.bmlSession = session
app.bmlAccounts = accounts
AccountCache.saveBml(this@HomeActivity, accounts)
app.bmlSessions[loginId] = session
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
Pair(session, accounts)
} catch (_: Exception) {
Pair(null, AccountCache.loadBml(this@HomeActivity))
Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
}
}
}
val fahipayJob = fahipayCreds?.let { creds ->
async(Dispatchers.IO) {
val fahipayFlow = FahipayLoginFlow()
val deviceUuid = store.getOrCreateFahipayDeviceUuid()
val savedSession = store.loadFahipaySession()
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 loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp
app.fahipaySession = session
app.fahipayAccounts = accounts
AccountCache.saveFahipay(this@HomeActivity, accounts)
return@async Pair(session, accounts)
} catch (_: Exception) {
}
}
try {
val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid)
if (step.twoFactorRequired) {
return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
}
val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
val cookieValue = fahipayFlow.getSessionCookieValue() ?: ""
val session = FahipaySession(authId, cookieValue)
store.saveFahipaySession(authId, cookieValue)
val profile = fahipayFlow.fetchProfile(session)
val balance = fahipayFlow.fetchBalance(session)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag))
val app = application as BasedBankApp
app.fahipaySession = session
app.fahipayAccounts = accounts
AccountCache.saveFahipay(this@HomeActivity, accounts)
Pair(session, accounts)
} catch (_: Exception) {
Pair(null, AccountCache.loadFahipay(this@HomeActivity))
}
}
}
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity)
val (bmlSession, bmlAccounts) = bmlJob?.await() ?: Pair(null, AccountCache.loadBml(this@HomeActivity))
viewModel.accounts.postValue(mibAccounts + bmlAccounts)
binding.refreshIndicator.visibility = View.GONE
val bmlResults = bmlJobs.map { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second }
val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity))
val app = application as BasedBankApp
if (bmlSession != null) {
refreshBmlContacts(app)
refreshBmlLimits(bmlSession)
}
app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts)
binding.refreshIndicator.visibility = View.GONE
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshFinancing(app.mibSession, app.mibProfiles)
refreshContacts(app.mibSession, app.mibProfiles)
}
}
@@ -384,20 +514,69 @@ class HomeActivity : AppCompatActivity() {
}
private fun refreshBmlContacts(app: BasedBankApp) {
val session = app.bmlSession ?: return
val bmlFlow = BmlLoginFlow()
if (app.bmlSessions.isEmpty()) return
lifecycleScope.launch {
try {
val bmlContacts = withContext(Dispatchers.IO) { bmlFlow.fetchContacts(session) }
if (bmlContacts.isNotEmpty()) {
ContactsCache.saveBml(this@HomeActivity, bmlContacts)
val mibContacts = viewModel.contacts.value ?: ContactsCache.loadContacts(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mibContacts, bmlContacts))
val allBmlContacts = withContext(Dispatchers.IO) {
app.bmlSessions.flatMap { (loginId, session) ->
val contacts = BmlLoginFlow().fetchContacts(session, loginId)
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 */ }
}
}
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
refreshContacts(app.mibSession, app.mibProfiles)
refreshBmlContacts(app)
if (app.fahipaySession != null) refreshFahipayContacts(app.fahipaySession!!)
}
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(
mib: List<MibBeneficiary>,
bml: List<MibBeneficiary>
@@ -411,8 +590,7 @@ class HomeActivity : AppCompatActivity() {
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(this))
val contactsClient = MibContactsClient()
lifecycleScope.launch {
try {
@@ -437,9 +615,12 @@ class HomeActivity : AppCompatActivity() {
}
if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(allContacts, bmlContacts))
viewModel.contactCategories.postValue(allCategories)
val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
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 */ }
}
@@ -449,24 +630,42 @@ class HomeActivity : AppCompatActivity() {
val app = application as BasedBankApp
lifecycleScope.launch {
val current = viewModel.accounts.value ?: emptyList()
if (src.profileType.startsWith("BML")) {
if (src.profileType == "FAHIPAY") {
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSession ?: return@withContext null
val sess = app.fahipaySession ?: return@withContext null
try {
val accounts = BmlLoginFlow().fetchAccounts(sess)
AccountCache.saveBml(this@HomeActivity, accounts)
app.bmlAccounts = accounts
val flow = FahipayLoginFlow()
flow.setSessionCookie(sess.sessionCookie)
val balance = flow.fetchBalance(sess)
val profile = flow.fetchProfile(sess)
val loginTag = "fahipay_${profile.profileId}"
val accounts = listOf(flow.buildAccount(profile, balance, loginTag))
AccountCache.saveFahipay(this@HomeActivity, accounts)
app.fahipayAccounts = accounts
accounts
} catch (_: Exception) { null }
} ?: return@launch
val mibOnly = current.filter { !it.profileType.startsWith("BML") }
viewModel.accounts.postValue(mibOnly + fresh)
val others = current.filter { it.profileType != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) {
val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null
try {
val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag)
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
app.bmlAccounts = otherBml + accounts
accounts
} catch (_: Exception) { null }
} ?: return@launch
val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag }
viewModel.accounts.postValue(otherAccounts + fresh)
} else {
val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
try { MibLoginFlow(prefs).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
catch (_: Exception) { null }
} ?: return@launch
// Replace accounts from this profile only, keep everything else
@@ -480,8 +679,7 @@ class HomeActivity : AppCompatActivity() {
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
if (session == null || profiles.isEmpty()) return
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(this))
val client = MibFinancingClient()
lifecycleScope.launch {
try {

View File

@@ -0,0 +1,48 @@
package sh.sar.basedbank.ui.home
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.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
class MoreFragment : Fragment() {
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
private val items = listOf(
NavItem(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItem(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItem(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItem(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
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 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.icon)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
list.addView(row)
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_more)
}
}

View File

@@ -0,0 +1,25 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
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?) {
view.findViewById<NavigationView>(R.id.navMoreView).setNavigationItemSelectedListener { item ->
dismiss()
onNavigate?.invoke(item.itemId)
true
}
}
}

View File

@@ -75,9 +75,10 @@ class OtpFragment : Fragment() {
val name = store.loadMibFullName()
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
}
store.loadBmlCredentials()?.let { creds ->
val name = store.loadBmlFullName()
entries.add(OtpEntry(if (name != null) "BML · $name" else "BML", creds.otpSeed))
for (loginId in store.getBmlLoginIds()) {
val creds = store.loadBmlCredentials(loginId) ?: continue
val name = store.loadBmlUserProfile(loginId)?.fullName
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
}
val adapter = OtpAdapter(entries)
@@ -105,13 +106,14 @@ class OtpFragment : Fragment() {
}
}
}
if (store.loadBmlFullName() == null) {
app.bmlSession?.let { session ->
for (loginId in store.getBmlLoginIds()) {
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
val session = app.bmlSessions[loginId] ?: continue
val info = withContext(Dispatchers.IO) {
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
}
if (info != null) {
store.saveBmlUserProfile(CredentialStore.BmlUserProfile(
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
fullName = info.fullName,
email = info.email,
mobile = info.mobile,
@@ -119,7 +121,8 @@ class OtpFragment : Fragment() {
idCard = info.idCard,
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 }
}
}

View File

@@ -7,11 +7,16 @@ import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
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.Preview
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.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import de.markusfisch.android.zxingcpp.ZxingCpp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
import java.util.concurrent.Executors
@@ -45,9 +52,11 @@ class QrScannerActivity : AppCompatActivity() {
textMode = ZxingCpp.TextMode.PLAIN
)
private var cameraStarted = false
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) startCamera() else finish() }
) { granted -> if (granted) startCamera() else showPermissionRationale() }
private val pickImageLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
@@ -78,8 +87,23 @@ class QrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityQrScannerBinding.inflate(layoutInflater)
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.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
@@ -89,7 +113,30 @@ class QrScannerActivity : AppCompatActivity() {
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() {
cameraStarted = true
val future = ProcessCameraProvider.getInstance(this)
future.addListener({
val provider = try {

View File

@@ -0,0 +1,75 @@
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.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
class SettingsAppearanceFragment : Fragment() {
private var _binding: FragmentSettingsAppearanceBinding? = null
private val binding get() = _binding!!
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?) {
val 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()
}
// 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))
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.settings_appearance)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -1,301 +1,51 @@
package sh.sar.basedbank.ui.home
import android.content.Context
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 android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.biometric.BiometricManager
import androidx.core.os.LocaleListCompat
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
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.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() {
private var _binding: FragmentSettingsBinding? = null
private val binding get() = _binding!!
private data class SettingsItem(
@DrawableRes val icon: Int,
@StringRes val title: Int,
val dest: () -> Fragment
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
return binding.root
}
private val items = listOf(
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins) { SettingsLoginsFragment() },
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?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Theme
val saved = prefs.getString("theme", "system")
val initialId = when (saved) {
"light" -> R.id.btnThemeLight
"dark" -> R.id.btnThemeDark
else -> R.id.btnThemeSystem
}
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
val list = view.findViewById<LinearLayout>(R.id.settingsList)
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.icon)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
row.setOnClickListener {
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
}
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))
}
// 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()
list.addView(row)
}
}
override fun onResume() {
super.onResume()
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)
}
}

View File

@@ -0,0 +1,212 @@
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 sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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 hasMib = store.hasMibCredentials()
val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials()
binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) 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_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) } }
)
}
}
for (loginId in bmlLoginIds) {
val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val profileNames = AccountCache.loadBml(ctx, loginId).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, loginId) } }
)
}
}
if (hasFahipay) {
val profile = store.loadFahipayUserProfile()
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) } }
)
}
}
}
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, loginId: String) {
val ctx = requireContext()
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
val app = requireActivity().application as BasedBankApp
app.bmlSessions.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
}
private fun logoutFahipay(store: CredentialStore) {
val ctx = requireContext()
store.clearFahipayCredentials(); store.clearFahipaySession()
val app = requireActivity().application as BasedBankApp
app.fahipaySession = null; app.fahipayAccounts = 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)
}
}

View File

@@ -0,0 +1,107 @@
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()
}
// 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)
}
}

View File

@@ -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)
}
}

View File

@@ -24,7 +24,9 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val displayItems = mutableListOf<Item>()
private val imageCache = mutableMapOf<String, Bitmap>()
private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
@@ -34,6 +36,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
var showLoadingFooter: Boolean
get() = _showLoadingFooter
@@ -103,7 +113,9 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
val name = trx.counterpartyName ?: trx.description
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) {
val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
rd.isCircular = true
@@ -117,7 +129,8 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
b.fvAvatar.background = circle
b.tvInitial.visibility = View.VISIBLE
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

View File

@@ -14,9 +14,16 @@ import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.Filter
import android.widget.Filterable
import android.graphics.Typeface
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
@@ -29,6 +36,8 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
@@ -41,6 +50,7 @@ import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
@@ -54,13 +64,19 @@ class TransferFragment : Fragment() {
private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession
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
private var resolvedAccountNumber = ""
private var resolvedRecipientName = ""
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 ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
@@ -80,6 +96,11 @@ class TransferFragment : Fragment() {
private const val ARG_SUBTITLE = "contact_subtitle"
private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_FROM_ACCOUNT = "from_account"
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
}
fun newInstance(
accountNumber: String,
@@ -109,15 +130,11 @@ class TransferFragment : Fragment() {
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
if (bundle.getBoolean(ContactPickerSheetFragment.KEY_SKIP_LOOKUP, false)) {
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
} else {
prefillToFromContact(accountNumber, label)
}
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
}
binding.btnPickContact.setOnClickListener {
@@ -143,19 +160,97 @@ class TransferFragment : Fragment() {
}
}
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() {
binding.btnClearFromInfo.setOnClickListener {
selectedAccount = null
binding.tilAmount.prefixText = null
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
}
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val adapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter)
// No default selection — user must explicitly pick a source account
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
selectedAccount = picked
binding.actvFrom.setText(picked.toDisplayString(), false)
updateAmountPrefix(picked)
showFromCard(picked)
}
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)
}
}
}
}
private fun showFromCard(account: MibAccount) {
val isBml = account.profileType.startsWith("BML")
val colorHex = when {
isBml -> "#0066A1"
account.profileType == "FAHIPAY" -> "#15BEA7"
else -> "#FE860E"
}
val bankLabel = when {
isBml -> "BML"
account.profileType == "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 (!isBml && 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.mibLoginFlow.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,7 +264,9 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener {
resolvedAccountNumber = ""
resolvedRecipientName = ""
selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE
binding.layoutServiceSelector.visibility = View.INVISIBLE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
@@ -190,7 +287,11 @@ class TransferFragment : Fragment() {
}
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()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return
@@ -210,8 +311,18 @@ class TransferFragment : Fragment() {
return
}
// Fahipay source: only phone numbers are supported
if (selectedAccount?.profileType == "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 bmlSess = bmlSession
val bmlSess = bmlSessionFor(selectedAccount)
if (mibSess == null && bmlSess == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
@@ -219,13 +330,18 @@ class TransferFragment : Fragment() {
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
binding.tilTo.isEnabled = false
startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null
val info = withContext(Dispatchers.IO) {
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) {
val bankId = when (bmlResult.trnType) {
"IAT" -> "MALBMVMV"
@@ -258,7 +374,7 @@ class TransferFragment : Fragment() {
}
}
}
binding.tilTo.isEnabled = true
stopLookupLoading()
if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
@@ -292,6 +408,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(
accountNumber: String,
displayName: String,
@@ -359,8 +568,8 @@ class TransferFragment : Fragment() {
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.profileType.startsWith("BML")
val isSrcCard = src.profileType == "BML_PREPAID"
val isDestMib = resolvedAccountNumber.matches(Regex("^9\\d{16}$"))
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" }
val allAccounts = viewModel.accounts.value ?: emptyList()
val allContacts = viewModel.contacts.value ?: emptyList()
@@ -381,32 +590,109 @@ class TransferFragment : Fragment() {
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
val bankNameCapture = resolvedBankName
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer)
.setMessage("Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}")
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
binding.btnTransfer.isEnabled = false
viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) {
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
} else {
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
}
}
binding.btnTransfer.isEnabled = true
if (ok && receipt != null) {
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 destCurrency = allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
?.currencyName?.ifBlank { "MVR" }
?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber }
?.transferCyDesc?.ifBlank { "MVR" }
?: if (isDestMib) "MVR" else "MVR"
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
val isSrcCredit = src.profileType == "BML_CREDIT"
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
val doTransfer: () -> Unit = {
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) {
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
} else {
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
}
}
binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) {
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 = AlertDialog.Builder(requireContext())
.setTitle(R.string.transfer)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.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(
@@ -430,7 +716,7 @@ class TransferFragment : Fragment() {
?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462"
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 toBank = when {
isDestMib -> "MIB"
@@ -489,8 +775,9 @@ class TransferFragment : Fragment() {
allAccounts: List<MibAccount>,
allContacts: List<MibBeneficiary>
): Triple<Boolean, String, TransferReceiptData?> {
val sess = bmlSession ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val otp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
val loginId = src.loginTag.removePrefix("bml_")
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) }
?: return Triple(false, "OTP unavailable", null)
val debitAccount = src.internalId.ifBlank {
@@ -498,7 +785,7 @@ class TransferFragment : Fragment() {
}
// 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 {
isSrcCard -> {
// CAD: card → own BML account
@@ -507,7 +794,7 @@ class TransferFragment : Fragment() {
}
isDestMyCard -> {
// 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?)
}
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
@@ -530,7 +817,7 @@ class TransferFragment : Fragment() {
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
// Step 2: confirm with fresh OTP
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?: otp
return try {
@@ -548,7 +835,8 @@ class TransferFragment : Fragment() {
remarks = remarks,
bmlFromName = src.accountBriefName,
bmlReference = result.reference,
bmlTimestamp = result.timestamp
bmlTimestamp = result.timestamp,
bmlMessage = result.message
)
val time = result.timestamp.take(19).replace("T", " ")
Triple(true, "Reference: ${result.reference}\n$time", receipt)
@@ -563,13 +851,17 @@ class TransferFragment : Fragment() {
private fun clearForm() {
selectedAccount = null
binding.actvFrom.setText("", false)
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.tilAmount.prefixText = null
binding.etAmount.setText("")
binding.etRemarks.setText("")
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedBankName = ""
selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE
binding.chipGroupService.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
@@ -675,8 +967,8 @@ class TransferFragment : Fragment() {
) : BaseAdapter(), Filterable {
private val items: List<Any> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" }
val cards = 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" || it.profileType == "BML_CREDIT" }
addAll(regular)
if (cards.isNotEmpty()) {
add(getString(R.string.cards))
@@ -685,7 +977,7 @@ class TransferFragment : Fragment() {
}
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 getItem(position: Int) = items[position]
@@ -716,8 +1008,10 @@ class TransferFragment : Fragment() {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it }
}
val inactive = acc.profileType == "BML_PREPAID" && !acc.statusDesc.equals("Active", ignoreCase = true)
b.tvDropdownAccountName.text = acc.accountBriefName
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isBmlAccount = acc.profileType.startsWith("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.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
b.root.alpha = if (inactive) 0.4f else 1f

View File

@@ -1,6 +1,8 @@
package sh.sar.basedbank.ui.home
import android.graphics.BitmapFactory
import okhttp3.OkHttpClient
import okhttp3.Request
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
@@ -22,6 +24,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
@@ -29,6 +32,7 @@ import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@@ -52,10 +56,13 @@ class TransferHistoryFragment : Fragment() {
var mibTotalCount: Int = -1,
var bmlNextPage: 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 {
account.profileType == "BML_PREPAID" -> cardMonthOffset < 2
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
}
@@ -66,6 +73,7 @@ class TransferHistoryFragment : Fragment() {
private var firstBatchDone = false
private val pageSize = 20
private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = mutableSetOf<String>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
@@ -75,6 +83,7 @@ class TransferHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = TransactionAdapter()
adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
@@ -134,7 +143,6 @@ class TransferHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession
val bmlSession = app.bmlSession
lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) {
@@ -146,8 +154,8 @@ class TransferHistoryFragment : Fragment() {
async {
try {
when {
state.account.profileType == "BML_PREPAID" -> {
val session = bmlSession ?: return@async emptyList()
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
@@ -161,7 +169,7 @@ class TransferHistoryFragment : Fragment() {
)
}
else -> {
val session = bmlSession ?: return@async emptyList()
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = state.account.internalId,
@@ -178,8 +186,29 @@ class TransferHistoryFragment : Fragment() {
}
}.awaitAll().flatten())
// Fahipay accounts
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" }
for (state in fahipayStates) {
val session = app.fahipaySession ?: 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
val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") }
val mibStates = activeStates.filter {
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY"
}
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break
app.mibMutex.withLock {
@@ -265,6 +294,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() {
(activity as? HomeActivity)?.setRefreshing(false)
super.onDestroyView()

View File

@@ -18,4 +18,5 @@ data class TransferReceiptData(
val bmlFromName: String = "",
val bmlReference: String = "",
val bmlTimestamp: String = "",
val bmlMessage: String = "",
)

View File

@@ -61,6 +61,7 @@ class TransferReceiptFragment : Fragment() {
private const val ARG_BML_FROM_NAME = "bml_from_name"
private const val ARG_BML_REFERENCE = "bml_reference"
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
var pendingToAvatarBitmap: Bitmap? = null
@@ -83,6 +84,7 @@ class TransferReceiptFragment : Fragment() {
putString(ARG_BML_FROM_NAME, data.bmlFromName)
putString(ARG_BML_REFERENCE, data.bmlReference)
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
putString(ARG_BML_MESSAGE, data.bmlMessage)
}
}
}
@@ -147,7 +149,7 @@ class TransferReceiptFragment : Fragment() {
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
binding.tvTransactionDate.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, "")
}
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
@@ -181,7 +183,9 @@ class TransferReceiptFragment : Fragment() {
binding.tvAmountValue.text = formattedAmount
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.tvTransactionDate.text = formatBmlTimestamp(args.getString(ARG_BML_TIMESTAMP, ""))
binding.tvFrom.text = args.getString(ARG_BML_FROM_NAME, "").ifBlank {
@@ -309,6 +313,12 @@ class TransferReceiptFragment : Fragment() {
override fun onResume() {
super.onResume()
requireActivity().title = "Receipt"
(activity as? HomeActivity)?.setBottomNavVisible(false)
}
override fun onPause() {
super.onPause()
(activity as? HomeActivity)?.setBottomNavVisible(true)
}
override fun onDestroyView() {

View File

@@ -27,6 +27,10 @@ class BankSelectionFragment : Fragment() {
val args = android.os.Bundle().apply { putString("bankType", "BML") }
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() {

View File

@@ -19,6 +19,8 @@ import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore
@@ -40,29 +42,61 @@ class CredentialsFragment : Fragment() {
private var _binding: FragmentCredentialsBinding? = null
private val binding get() = _binding!!
// Fahipay two-step state
private var fahipayFlow: FahipayLoginFlow? = null
private var fahipayAwaitingTotp = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (bankType == "BML") {
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector)
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
when (bankType) {
"BML" -> {
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector)
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.etOtpSeed.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() }
val loginFieldWatcher = object : TextWatcher {
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 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() {
super.onResume()
otpHandler.post(otpRunnable)
if (bankType != "FAHIPAY") otpHandler.post(otpRunnable)
}
override fun onPause() {
@@ -70,10 +104,35 @@ class CredentialsFragment : Fragment() {
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() {
val seed = binding.etOtpSeed.text.toString().trim()
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val seed = resolveOtpSeed(otpSeedRaw)
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
}
try {
@@ -86,19 +145,19 @@ class CredentialsFragment : Fragment() {
binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE
} catch (e: Exception) {
binding.cardOtp.visibility = View.GONE
binding.cardOtp.visibility = View.INVISIBLE
}
}
private fun attemptLogin() {
if (bankType == "BML") {
attemptBmlLogin()
return
when (bankType) {
"BML" -> { attemptBmlLogin(); return }
"FAHIPAY" -> { attemptFahipayLogin(); return }
}
val username = binding.etUsername.text.toString().trim()
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()) {
binding.tvError.text = "Please fill in all fields"
@@ -111,8 +170,7 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password)
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
val flow = MibLoginFlow(prefs)
val flow = MibLoginFlow(CredentialStore(requireContext()))
viewLifecycleOwner.lifecycleScope.launch {
try {
@@ -156,7 +214,7 @@ class CredentialsFragment : Fragment() {
private fun attemptBmlLogin() {
val username = binding.etUsername.text.toString().trim()
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()) {
binding.tvError.text = "Please fill in all fields"
@@ -168,6 +226,7 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
val loginId = username
val flow = BmlLoginFlow()
viewLifecycleOwner.lifecycleScope.launch {
try {
@@ -175,11 +234,12 @@ class CredentialsFragment : Fragment() {
flow.login(username, password, otpSeed)
}
val store = CredentialStore(requireContext())
store.saveBmlCredentials(username, password, otpSeed)
store.saveBmlSession(session.accessToken, session.deviceId)
store.saveBmlCredentials(loginId, username, password, otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
withContext(Dispatchers.IO) {
val info = flow.fetchUserInfo(session)
if (info != null) store.saveBmlUserProfile(
loginId,
CredentialStore.BmlUserProfile(
fullName = info.fullName,
email = info.email,
@@ -190,11 +250,11 @@ class CredentialsFragment : Fragment() {
)
)
}
AccountCache.saveBml(requireContext(), accounts)
AccountCache.saveBml(requireContext(), loginId, accounts)
val app = requireActivity().application as BasedBankApp
app.bmlSession = session
app.bmlAccounts = accounts
// Merge with any existing MIB accounts already in app
app.bmlSessions[loginId] = session
// Merge with any existing BML accounts from other logins
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
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
@@ -209,6 +269,136 @@ class CredentialsFragment : Fragment() {
}
}
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)
}
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
} finally {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
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 loginTag = "fahipay_${profile.profileId}"
val account = flow.buildAccount(profile, balance, loginTag)
store.saveFahipayCredentials(idCard, password)
store.saveFahipaySession(session.authId, session.sessionCookie)
store.saveFahipayUserProfile(
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(), listOf(account))
val app = requireActivity().application as BasedBankApp
app.fahipaySession = session
app.fahipayAccounts = listOf(account)
app.accounts = app.accounts + 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() {
super.onDestroyView()
_binding = null

View File

@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.login
import android.content.res.Configuration
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import sh.sar.basedbank.databinding.ActivityLoginBinding
class LoginActivity : AppCompatActivity() {
@@ -10,7 +12,13 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater)
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
}
}
}

View File

@@ -3,10 +3,14 @@ package sh.sar.basedbank.ui.onboarding
import android.content.Intent
import android.content.SharedPreferences
import android.os.Bundle
import android.os.CountDownTimer
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
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 com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.R
@@ -17,46 +21,68 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private lateinit var binding: ActivityOnboardingBinding
private lateinit var prefs: SharedPreferences
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityOnboardingBinding.inflate(layoutInflater)
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)
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
}
val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
// Pre-select language chip without triggering the listener
val savedLang = prefs.getString("language", null)
binding.languageChipGroup.setOnCheckedStateChangeListener(null)
when (savedLang) {
"en" -> binding.chipEnglish.isChecked = true
"dv" -> binding.chipDhivehi.isChecked = true
}
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isNotEmpty()) {
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
// Disable tap-to-navigate on dots: touch listener must be on the individual
// tab views inside SlidingTabStrip (child 0), because they consume ACTION_DOWN
// before the TabLayout's own touch listener ever fires.
val tabStrip = binding.dotsIndicator.getChildAt(0) as? android.view.ViewGroup
tabStrip?.let {
for (i in 0 until it.childCount) {
it.getChildAt(i).setOnTouchListener { _, _ -> true }
}
}
// Pre-select language button without triggering the listener
val savedLang = prefs.getString("language", null)
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() {
override fun onPageSelected(position: Int) {
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE
// Block forward swipe on slide 1 until security is set up
if (position == 1) {
binding.viewPager.isUserInputEnabled =
prefs.getString("security_method", null) != null
} else {
binding.viewPager.isUserInputEnabled = true
binding.languageSection.visibility = if (position == 0) View.VISIBLE else View.GONE
binding.viewPager.isUserInputEnabled = when {
position > 2 -> false
position == 1 -> prefs.getString("security_method", null) != null
else -> true
}
updateButtons(position, adapter.itemCount)
}
})
binding.languageChipGroup.visibility = View.VISIBLE
binding.languageSection.visibility = View.VISIBLE
updateButtons(0, adapter.itemCount)
binding.btnNext.setOnClickListener {
@@ -71,10 +97,21 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
}
}
override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
}
// Called by SecuritySetupFragment when setup is complete
override fun onSecuritySetupComplete() {
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) {
@@ -83,23 +120,34 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
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) {
val langSelected = prefs.getString("language", null) != null
val securityDone = prefs.getString("security_method", null) != null
val isLast = position == count - 1
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 = when {
isLast -> View.GONE
position == 1 && !securityDone -> View.GONE
else -> View.VISIBLE
}
binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
binding.btnNext.isEnabled = when (position) {
0 -> langSelected
1 -> securityDone
else -> true
else -> true // position 2 (configure) has no gate
}
}
}

View File

@@ -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
}
}

View File

@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.ScrollView
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
@@ -12,6 +14,7 @@ class OnboardingFragment : Fragment() {
private var _binding: FragmentOnboardingSlideBinding? = null
private val binding get() = _binding!!
private var scrolledToBottom = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
@@ -19,17 +22,45 @@ class OnboardingFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val title = requireArguments().getString(ARG_TITLE, "")
val desc = requireArguments().getString(ARG_DESC, "")
val titleRes = requireArguments().getInt(ARG_TITLE)
val descRes = requireArguments().getInt(ARG_DESC)
val icon = requireArguments().getInt(ARG_ICON, 0)
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.title.text = title
binding.description.text = desc
binding.title.text = getString(titleRes)
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
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() {
@@ -38,17 +69,20 @@ class OnboardingFragment : Fragment() {
}
companion object {
const val RESULT_SCROLLED_TO_BOTTOM = "scroll_to_bottom"
private const val ARG_TITLE = "title"
private const val ARG_DESC = "desc"
private const val ARG_ICON = "icon"
private const val ARG_IS_FIRST = "is_first"
private const val ARG_IS_LAST = "is_last"
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
arguments = bundleOf(
ARG_TITLE to slide.title,
ARG_DESC to slide.description,
ARG_TITLE to slide.titleRes,
ARG_DESC to slide.descRes,
ARG_ICON to slide.iconRes,
ARG_IS_FIRST to slide.isFirst
ARG_IS_FIRST to slide.isFirst,
ARG_IS_LAST to slide.isLast
)
}
}

View File

@@ -9,36 +9,39 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
private val slides = listOf(
OnboardingSlide(
title = activity.getString(R.string.onboarding_title_1),
description = activity.getString(R.string.onboarding_desc_1),
titleRes = R.string.onboarding_title_1,
descRes = R.string.onboarding_desc_1,
iconRes = R.drawable.ic_launcher_foreground,
isFirst = true
),
OnboardingSlide(
title = activity.getString(R.string.onboarding_title_2),
description = activity.getString(R.string.onboarding_desc_2),
titleRes = R.string.onboarding_title_2,
descRes = R.string.onboarding_desc_2,
iconRes = R.drawable.ic_launcher_foreground,
isFirst = false
),
OnboardingSlide(
title = activity.getString(R.string.onboarding_title_3),
description = activity.getString(R.string.onboarding_desc_3),
titleRes = R.string.onboarding_title_3,
descRes = R.string.onboarding_desc_3,
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) {
1 -> SecuritySetupFragment()
else -> OnboardingFragment.newInstance(slides[position])
2 -> OnboardingConfigureFragment()
else -> OnboardingFragment.newInstance(slides[position - if (position > 2) 1 else 0])
}
}
data class OnboardingSlide(
val title: String,
val description: String,
val titleRes: Int,
val descRes: Int,
val iconRes: Int,
val isFirst: Boolean
val isFirst: Boolean,
val isLast: Boolean = false
)

View File

@@ -86,17 +86,21 @@ class PatternView @JvmOverloads constructor(
if (errorState) return false
when (event.action) {
MotionEvent.ACTION_DOWN -> {
parent?.requestDisallowInterceptTouchEvent(true)
recording = true; selected.clear()
hit(event.x, event.y)
}
MotionEvent.ACTION_MOVE -> {
parent?.requestDisallowInterceptTouchEvent(true)
touchX = event.x; touchY = event.y
hit(event.x, event.y)
}
MotionEvent.ACTION_UP -> {
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent?.requestDisallowInterceptTouchEvent(false)
recording = false
invalidate()
onPatternComplete?.invoke(selected.map { it.index })
if (event.action == MotionEvent.ACTION_UP)
onPatternComplete?.invoke(selected.map { it.index })
}
}
invalidate()

View File

@@ -7,18 +7,20 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
import java.security.MessageDigest
import sh.sar.basedbank.util.CredentialStore
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
class SecuritySetupFragment : Fragment() {
interface Callback {
fun onSecuritySetupComplete()
fun onSecuritySetupReset()
}
companion object {
@@ -31,7 +33,7 @@ class SecuritySetupFragment : Fragment() {
private var _b: FragmentSecuritySetupBinding? = null
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 val pinDigits = mutableListOf<Int>()
@@ -46,9 +48,6 @@ class SecuritySetupFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
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.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
@@ -60,20 +59,21 @@ class SecuritySetupFragment : Fragment() {
}
}
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.btnEnableBiometrics.setOnClickListener {
prefs.edit().putBoolean("biometrics_enabled", true).apply()
finishSetup()
}
b.btnSkipBiometrics.setOnClickListener {
prefs.edit().putBoolean("biometrics_enabled", false).apply()
finishSetup()
}
buildNumpad()
goTo(Step.CHOOSE)
if (!changeMode && prefs.getString("security_method", null) != null) {
goTo(Step.CONFIGURED)
} else {
goTo(Step.CHOOSE)
}
}
private fun buildNumpad() {
@@ -142,7 +142,7 @@ class SecuritySetupFragment : Fragment() {
Step.PIN_CONFIRM -> {
if (entered == firstPin) {
saveCredential("pin", entered)
goToBiometricOrFinish()
finishSetup()
} else {
b.tvPinDots.text = getString(R.string.pin_no_match)
pinDigits.clear()
@@ -170,7 +170,7 @@ class SecuritySetupFragment : Fragment() {
Step.PATTERN_CONFIRM -> {
if (pattern == firstPattern) {
saveCredential("pattern", pattern.joinToString(""))
goToBiometricOrFinish()
finishSetup()
} else {
b.patternView.showError()
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) {
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.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.viewBiometric.visibility = if (s == Step.BIOMETRIC) View.VISIBLE else View.GONE
when (s) {
Step.PIN_ENTER -> {
@@ -231,20 +214,27 @@ class SecuritySetupFragment : Fragment() {
private fun saveCredential(method: String, input: String) {
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
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()
.putString("security_method", method)
.putString("security_salt", saltB64)
.putString("security_hash", hash)
.apply()
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
}
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
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()
}
}
private fun finishSetup() {
val cb = activity as? Callback
if (cb != null) {
goTo(Step.CONFIGURED)
cb.onSecuritySetupComplete()
} else {
parentFragmentManager.popBackStack()

View File

@@ -9,7 +9,9 @@ object AccountCache {
private const val PREFS = "account_cache"
private const val KEY_MIB = "mib_accounts"
private const val KEY_BML = "bml_accounts"
private const val KEY_FAHIPAY = "fahipay_accounts"
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
fun save(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray()
@@ -32,10 +34,10 @@ object AccountCache {
})
}
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()
for (acc in accounts) {
arr.put(JSONObject().apply {
@@ -55,14 +57,14 @@ object AccountCache {
})
}
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> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML, null) ?: return emptyList()
fun loadBml(context: Context, loginId: String): List<MibAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(json)
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
@@ -82,7 +84,60 @@ object AccountCache {
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, 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(KEY_FAHIPAY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadFahipay(context: Context): List<MibAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_FAHIPAY, null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibAccount(
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 clear(context: Context) {
@@ -90,9 +145,10 @@ object AccountCache {
}
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()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,28 @@
package sh.sar.basedbank.util
object BmlDashboardParser {
/**
* Returns a display-ready product label for a BML dashboard account or card.
* Known BML product names are mapped to short friendly labels.
* Everything else is title-cased (first letter of each word capitalised).
*/
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)
}
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}

View 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()
}
}

View File

@@ -37,7 +37,7 @@ object ContactsCache {
put("profileId", c.profileId)
})
}
prefs.putString(KEY_CONTACTS, contactsArr.toString())
prefs.putString(KEY_CONTACTS, CacheEncryption.encrypt(contactsArr.toString()))
val catArr = JSONArray()
for (cat in categories) {
@@ -47,7 +47,7 @@ object ContactsCache {
put("numBenef", cat.numBenef)
})
}
prefs.putString(KEY_CATEGORIES, catArr.toString())
prefs.putString(KEY_CATEGORIES, CacheEncryption.encrypt(catArr.toString()))
prefs.apply()
}
@@ -56,9 +56,10 @@ object ContactsCache {
}
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()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { 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()
for (c in contacts) {
arr.put(JSONObject().apply {
@@ -98,17 +101,18 @@ object ContactsCache {
put("benefStatus", c.benefStatus)
put("transferCyDesc", c.transferCyDesc)
put("benefCategoryId", c.benefCategoryId)
put("profileId", c.profileId)
})
}
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> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("bml_contacts", null) ?: return emptyList()
fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(json)
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
MibBeneficiary(
@@ -123,16 +127,81 @@ object ContactsCache {
benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"),
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> {
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_CATEGORIES, null) ?: return emptyList()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import org.json.JSONObject
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
@@ -18,9 +19,12 @@ class CredentialStore(context: Context) {
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 FahipayCredentials(val idCard: String, val password: String)
// ── MIB login credentials ─────────────────────────────────────────────────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card")
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
val key = getOrCreateKey()
@@ -42,9 +46,7 @@ class CredentialStore(context: Context) {
decrypt(encHash, key),
decrypt(encSeed, key)
)
} catch (e: Exception) {
null
}
} catch (_: Exception) { null }
}
fun clearMibCredentials() {
@@ -52,60 +54,264 @@ class CredentialStore(context: Context) {
.remove("mib_enc_username")
.remove("mib_enc_password_hash")
.remove("mib_enc_otp_seed")
.remove("mib_enc_key1")
.remove("mib_enc_key2")
.remove("mib_enc_app_id")
.apply()
}
fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
// ── MIB session keys (key1/key2) and app ID ───────────────────────────────
fun saveMibKeys(key1: String, key2: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("bml_enc_username", encrypt(username, key))
.putString("bml_enc_password", encrypt(password, key))
.putString("bml_enc_otp_seed", encrypt(otpSeed, key))
.putString("mib_enc_key1", encrypt(key1, key))
.putString("mib_enc_key2", encrypt(key2, key))
.apply()
}
fun loadBmlCredentials(): BmlCredentials? {
fun loadMibKeys(): Pair<String, String>? {
val key = getOrCreateKey()
val encUsername = prefs.getString("bml_enc_username", null) ?: return null
val encPassword = prefs.getString("bml_enc_password", null) ?: return null
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null
return try {
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
} catch (_: Exception) { null }
}
fun saveMibAppId(id: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply()
}
fun loadMibAppId(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_app_id", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
// ── 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 {
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
} catch (e: Exception) { null }
} catch (_: Exception) { null }
}
fun clearBmlCredentials() {
fun clearBmlCredentials(loginId: String) {
removeBmlLoginId(loginId)
prefs.edit()
.remove("bml_enc_username")
.remove("bml_enc_password")
.remove("bml_enc_otp_seed")
.remove("bml_${loginId}_enc_username")
.remove("bml_${loginId}_enc_password")
.remove("bml_${loginId}_enc_otp_seed")
.apply()
}
fun saveBmlSession(accessToken: String, deviceId: String) {
// ── BML session token (per loginId) ───────────────────────────────────────
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("bml_enc_token", encrypt(accessToken, key))
.putString("bml_enc_device_id", encrypt(deviceId, key))
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
.apply()
}
fun loadBmlSession(): Pair<String, String>? {
fun loadBmlSession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey()
val encToken = prefs.getString("bml_enc_token", null) ?: return null
val encDeviceId = prefs.getString("bml_enc_device_id", null) ?: return null
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 }
}
fun clearBmlSession() {
fun clearBmlSession(loginId: String) {
prefs.edit()
.remove("bml_enc_token")
.remove("bml_enc_device_id")
.remove("bml_${loginId}_enc_token")
.remove("bml_${loginId}_enc_device_id")
.apply()
}
// ── Fahipay login credentials ─────────────────────────────────────────────
fun saveFahipayCredentials(idCard: String, password: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("fahipay_enc_id_card", encrypt(idCard, key))
.putString("fahipay_enc_password", encrypt(password, key))
.apply()
}
fun loadFahipayCredentials(): FahipayCredentials? {
val key = getOrCreateKey()
val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null
val encPw = prefs.getString("fahipay_enc_password", null) ?: return null
return try {
FahipayCredentials(decrypt(encId, key), decrypt(encPw, key))
} catch (_: Exception) { null }
}
fun clearFahipayCredentials() {
prefs.edit()
.remove("fahipay_enc_id_card")
.remove("fahipay_enc_password")
.apply()
}
// ── Fahipay session (authId + __Secure-sess cookie) ───────────────────────
fun saveFahipaySession(authId: String, sessionCookie: String) {
val key = getOrCreateKey()
prefs.edit()
.putString("fahipay_enc_auth_id", encrypt(authId, key))
.putString("fahipay_enc_session_cookie", encrypt(sessionCookie, key))
.apply()
}
fun loadFahipaySession(): Pair<String, String>? {
val key = getOrCreateKey()
val encAuth = prefs.getString("fahipay_enc_auth_id", null) ?: return null
val encCookie = prefs.getString("fahipay_enc_session_cookie", null) ?: return null
return try {
Pair(decrypt(encAuth, key), decrypt(encCookie, key))
} catch (_: Exception) { null }
}
fun clearFahipaySession() {
prefs.edit()
.remove("fahipay_enc_auth_id")
.remove("fahipay_enc_session_cookie")
.apply()
}
// ── 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 ──────────────────────────────────────────────────
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(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_enc_profile", encrypt(json, key)).apply()
}
fun loadFahipayUserProfile(): FahipayUserProfile? {
val key = getOrCreateKey()
val enc = prefs.getString("fahipay_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 }
}
// ── 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(
val fullName: String,
val username: String,
@@ -123,54 +329,78 @@ class CredentialStore(context: Context) {
val birthdate: String
)
fun saveMibFullName(name: String) = prefs.edit().putString("mib_full_name", name).apply()
fun loadMibFullName(): String? = prefs.getString("mib_full_name", null)
fun saveMibFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
}
fun loadMibFullName(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("mib_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveBmlFullName(name: String) = prefs.edit().putString("bml_full_name", name).apply()
fun loadBmlFullName(): String? = prefs.getString("bml_full_name", null)
fun saveMibUserProfile(p: MibUserProfile) {
prefs.edit().putString("mib_full_name", p.fullName)
.putString("mib_profile_username", p.username)
.putString("mib_profile_email", p.email)
.putString("mib_profile_mobile", p.mobile)
.putString("mib_profile_enrolled", p.enrolled)
.apply()
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_enc_profile", encrypt(json, key)).apply()
// Keep the name in sync with the fast-path field
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
}
fun loadMibUserProfile(): MibUserProfile? {
val name = prefs.getString("mib_full_name", null) ?: return null
return MibUserProfile(
fullName = name,
username = prefs.getString("mib_profile_username", "") ?: "",
email = prefs.getString("mib_profile_email", "") ?: "",
mobile = prefs.getString("mib_profile_mobile", "") ?: "",
enrolled = prefs.getString("mib_profile_enrolled", "") ?: ""
)
val key = getOrCreateKey()
val enc = prefs.getString("mib_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(p: BmlUserProfile) {
prefs.edit().putString("bml_full_name", p.fullName)
.putString("bml_profile_email", p.email)
.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 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(): BmlUserProfile? {
val name = prefs.getString("bml_full_name", null) ?: return null
return BmlUserProfile(
fullName = name,
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 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 }
}
// ── Crypto primitives ─────────────────────────────────────────────────────
private fun getOrCreateKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
@@ -193,16 +423,16 @@ class CredentialStore(context: Context) {
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, key)
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)
}
private fun decrypt(encoded: String, key: SecretKey): String {
val parts = encoded.split(":")
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
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, key, GCMParameterSpec(128, iv))
return String(cipher.doFinal(ct))
return String(cipher.doFinal(ct), Charsets.UTF_8)
}
}

View File

@@ -31,7 +31,7 @@ object FinancingCache {
})
}
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) {
@@ -39,9 +39,10 @@ object FinancingCache {
}
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()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View File

@@ -39,7 +39,7 @@ object ForeignLimitsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -47,9 +47,10 @@ object ForeignLimitsCache {
}
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()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val entry = arr.getJSONObject(i)

View 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 }
}

View File

@@ -0,0 +1,18 @@
package sh.sar.basedbank.util
object MibAccountParser {
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
* Known MIB accountTypeName values are mapped to short friendly labels.
* Everything else is returned trimmed as-is.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}

View File

@@ -37,7 +37,7 @@ object RecentsCache {
})
}
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) {
@@ -54,7 +54,7 @@ object RecentsCache {
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY, arr.toString()).apply()
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
}
fun clear(context: Context) {
@@ -62,9 +62,10 @@ object RecentsCache {
}
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()
return try {
val json = CacheEncryption.decrypt(raw)
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View 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>

View 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>

View 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>

View File

@@ -5,6 +5,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<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" />
</vector>

View File

@@ -3,5 +3,5 @@
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<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>

View 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>

View 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>

View 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>

View 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>

View 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="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector>

View 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.99,11L3,15l3.99,4v-3H14v-2H6.99v-3zM21,9l-3.99,-4v3H10v2h7.01v3L21,9z" />
</vector>

View 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="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z" />
</vector>

View 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="M12,3c-4.97,0 -9,4.03 -9,9s4.03,9 9,9c0.83,0 1.5,-0.67 1.5,-1.5 0,-0.39 -0.15,-0.74 -0.39,-1.01 -0.23,-0.26 -0.38,-0.61 -0.38,-0.99 0,-0.83 0.67,-1.5 1.5,-1.5H16c2.76,0 5,-2.24 5,-5 0,-4.42 -4.03,-8 -9,-8zM6.5,12C5.67,12 5,11.33 5,10.5S5.67,9 6.5,9 8,9.67 8,10.5 7.33,12 6.5,12zM9.5,8C8.67,8 8,7.33 8,6.5S8.67,5 9.5,5 11,5.67 11,6.5 10.33,8 9.5,8zM14.5,8C13.67,8 13,7.33 13,6.5S13.67,5 14.5,5 16,5.67 16,6.5 15.33,8 14.5,8zM17.5,12C16.67,12 16,11.33 16,10.5S16.67,9 17.5,9 19,9.67 19,10.5 18.33,12 17.5,12z" />
</vector>

View 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="M2,20h20v-4H2v4zM4,17h2v2H4v-2zM2,4v4h20V4H2zM6,7H4V5h2v2zM2,14h20v-4H2v4zM4,11h2v2H4v-2z" />
</vector>

View 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="#FF5F00"
android:pathData="M11.343 18.031c.058.049.12.098.181.146-1.177.783-2.59 1.238-4.107 1.238C3.32 19.416 0 16.096 0 12c0-4.095 3.32-7.416 7.416-7.416 1.518 0 2.931.456 4.105 1.238-.06.051-.12.098-.165.15C9.6 7.489 8.595 9.688 8.595 12c0 2.311 1.001 4.51 2.748 6.031zm5.241-13.447c-1.52 0-2.931.456-4.105 1.238.06.051.12.098.165.15C14.4 7.489 15.405 9.688 15.405 12c0 2.31-1.001 4.507-2.748 6.031-.058.049-.12.098-.181.146 1.177.783 2.588 1.238 4.107 1.238C20.68 19.416 24 16.096 24 12c0-4.094-3.32-7.416-7.416-7.416zM12 6.174c-.096.075-.189.15-.28.231C10.156 7.764 9.169 9.765 9.169 12c0 2.236.987 4.236 2.551 5.595.09.08.185.158.28.232.096-.074.189-.152.28-.232 1.563-1.359 2.551-3.359 2.551-5.595 0-2.235-.987-4.236-2.551-5.595-.09-.08-.184-.156-.28-.231z" />
</vector>

View 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="#1A1F71"
android:pathData="M9.112 8.262L5.97 15.758H3.92L2.374 9.775c-0.094,-0.368,-0.175,-0.503,-0.461,-0.658C1.447 8.864 0.677 8.627 0 8.479l0.046,-0.217h3.3a0.904,0.904,0,0,1,0.894,0.764l0.817 4.338 2.018,-5.102zM17.145 13.311c0.008,-1.979,-2.736,-2.088,-2.717,-2.972 0.006,-0.269 0.262,-0.555 0.822,-0.628a3.66,3.66,0,0,1,1.913,0.336l0.34,-1.59a5.207,5.207,0,0,0,-1.814,-0.333c-1.917 0,-3.266 1.02,-3.278 2.479,-0.012 1.079 0.963 1.68 1.698 2.04 0.756 0.367 1.01 0.603 1.006 0.931,-0.005 0.504,-0.602 0.725,-1.16 0.734,-0.975 0.015,-1.54,-0.263,-1.992,-0.473l-0.351 1.642c0.453 0.208 1.289 0.39 2.156 0.398 2.037 0 3.37,-1.006 3.377,-2.564zM22.206 15.758H24l-1.565,-7.496h-1.656a0.883,0.883,0,0,0,-0.826,0.55l-2.909 6.946h2.036l0.405,-1.12h2.488zM20.043 13.102l1.02,-2.815 0.588 2.815zM11.883 8.262l-1.603 7.496H8.34l1.605,-7.496z" />
</vector>

View File

@@ -4,17 +4,20 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<!-- Main content -->
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
@@ -32,11 +35,27 @@
</com.google.android.material.appbar.AppBarLayout>
<FrameLayout
android:id="@+id/contentFrame"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<FrameLayout
android:id="@+id/contentFrame"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottomNavigation"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -46,6 +65,7 @@
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout>

View File

@@ -4,7 +4,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/navHostFragment"

View File

@@ -27,31 +27,49 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.chip.ChipGroup
android:id="@+id/languageChipGroup"
android:layout_width="wrap_content"
<LinearLayout
android:id="@+id/languageSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:orientation="vertical"
android:layout_marginBottom="16dp"
android:visibility="gone"
app:singleSelection="true"
app:selectionRequired="false">
android:visibility="gone">
<com.google.android.material.chip.Chip
android:id="@+id/chipEnglish"
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="English"
style="@style/Widget.Material3.Chip.Filter" />
android:layout_gravity="center_horizontal"
android:text="@string/select_language"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.chip.Chip
android:id="@+id/chipDhivehi"
android:layout_width="wrap_content"
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/languageToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="ދިވެހި"
style="@style/Widget.Material3.Chip.Filter" />
app:singleSelection="true"
app:selectionRequired="true">
</com.google.android.material.chip.ChipGroup>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangEnglish"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="English" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangDhivehi"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ދިވެހި" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</LinearLayout>
<com.google.android.material.tabs.TabLayout
android:id="@+id/dotsIndicator"
@@ -61,6 +79,8 @@
android:background="@null"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="24dp"
android:clickable="false"
android:focusable="false"
app:tabBackground="@drawable/tab_indicator_selector"
app:tabGravity="center"
app:tabIndicatorHeight="0dp"

View File

@@ -10,6 +10,7 @@
android:layout_height="match_parent" />
<LinearLayout
android:id="@+id/btnContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"

View File

@@ -117,5 +117,49 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Fahipay Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardFahipay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<ImageView
android:layout_width="138dp"
android:layout_height="40dp"
android:src="@drawable/fahipay_logo_long"
android:contentDescription="@string/fahipay_name"
android:scaleType="fitStart"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/fahipay_name"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/fahipay_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -42,6 +42,7 @@
android:layout_marginBottom="32dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
@@ -73,12 +74,12 @@
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilOtpSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/otp_seed"
android:layout_marginBottom="8dp"
app:endIconMode="password_toggle"
app:helperText="@string/otp_seed_hint"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etOtpSeed"
@@ -89,13 +90,32 @@
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTotpCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/fahipay_totp_code"
android:layout_marginBottom="8dp"
app:helperText="@string/fahipay_totp_hint"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTotpCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:imeOptions="actionDone"
android:singleLine="true"
android:maxLength="6" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOtp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:visibility="gone"
android:visibility="invisible"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:id="@+id/moreList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingTop="8dp"
android:paddingBottom="8dp" />
</ScrollView>

View File

@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:paddingTop="40dp">
<!-- Appearance -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_appearance"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_navigation"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/navModeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavDrawer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_bottom" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/theme"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/themeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeSystem"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_system" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeLight"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_light" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeDark"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_dark" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Privacy & Security -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_privacy_security"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<!-- Biometrics -->
<LinearLayout
android:id="@+id/rowBiometrics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvBiometricsHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics_unavailable"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_unlock"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_transfer"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometricsTransfer"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<!-- Block screenshots -->
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_block_screenshots"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_block_screenshots_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBlockScreenshots"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -1,94 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingHorizontal="32dp"
android:paddingTop="64dp"
android:paddingBottom="16dp">
<LinearLayout
<ImageView
android:id="@+id/icon"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="40dp"
android:contentDescription="@string/app_name" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingHorizontal="32dp"
android:paddingTop="64dp"
android:paddingBottom="16dp">
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="16dp" />
<ImageView
android:id="@+id/icon"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="40dp"
android:contentDescription="@string/app_name" />
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:lineSpacingMultiplier="1.4" />
<!-- Bank logo cards shown only on first slide -->
<LinearLayout
android:id="@+id/placeholderCards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="40dp"
android:weightSum="2"
android:visibility="gone">
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardCornerRadius="12dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:lineSpacingMultiplier="1.4" />
<!-- Supported services shown only on first slide -->
<LinearLayout
android:id="@+id/placeholderCards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="40dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="@string/onboarding_supported_services"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp" />
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="40dp"
android:src="@drawable/mib_faisanet_logo"
android:scaleType="centerInside"
android:padding="12dp"
android:contentDescription="@string/mib_name" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardCornerRadius="12dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="40dp"
android:layout_marginTop="24dp"
android:src="@drawable/bml_logo_vector"
android:scaleType="centerInside"
android:padding="8dp"
android:contentDescription="@string/bml_name" />
</com.google.android.material.card.MaterialCardView>
<ImageView
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="24dp"
android:src="@drawable/fahipay_logo_long"
android:scaleType="centerInside"
android:contentDescription="@string/fahipay_name" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>
</ScrollView>
</LinearLayout>

View File

@@ -4,7 +4,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#FFFFFF">
android:background="?attr/colorSurface">
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Renderable receipt card -->
@@ -13,6 +13,7 @@
android:id="@+id/receiptCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:orientation="vertical"
android:background="#FFFFFF">
@@ -36,7 +37,6 @@
android:layout_marginTop="14dp"
android:layout_marginBottom="20dp"
android:id="@+id/tvMessage"
android:text="Thank you. Transfer transaction is successful."
android:textSize="14sp"
android:textColor="#2D2D2D"
android:fontFamily="@font/nunito_sans"
@@ -50,7 +50,7 @@
<!-- ══════════════════════════════════════════════════════════════════ -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="70dp">
<ImageView
android:layout_width="match_parent"
@@ -59,38 +59,32 @@
android:scaleType="fitXY"
android:contentDescription="@null" />
<LinearLayout
android:layout_width="match_parent"
<TextView
android:id="@+id/tvAmountValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingVertical="14dp">
<!-- Amount — SofiaPro 68px → 42sp, #242424 -->
<TextView
android:id="@+id/tvAmountValue"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="42sp"
android:textColor="#242424"
android:fontFamily="@font/sofia_pro"
android:gravity="center" />
<!-- Currency — SofiaPro 40px → 20sp, #7E7E7E -->
<TextView
android:id="@+id/tvAmountCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="20sp"
android:textColor="#7E7E7E"
android:fontFamily="@font/sofia_pro"
android:gravity="center" />
</LinearLayout>
android:layout_gravity="center"
android:layout_marginBottom="13dp"
android:textSize="42sp"
android:textColor="#242424"
android:fontFamily="@font/sofia_pro"
android:gravity="center" />
</FrameLayout>
<!-- Currency — floats below wave on transparent background -->
<TextView
android:id="@+id/tvAmountCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="-19dp"
android:layout_marginBottom="0dp"
android:textSize="20sp"
android:textColor="#7E7E7E"
android:fontFamily="@font/sofia_pro"
android:gravity="center" />
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- Body rows -->
<!-- Labels: SofiaPro 28px → 13sp, #000000, left-aligned -->
@@ -150,7 +144,7 @@
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="13sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
</LinearLayout>
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
@@ -212,12 +206,6 @@
</LinearLayout>
<!-- Pushes buttons to bottom of screen -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Action buttons — outside renderable area -->
<!-- ══════════════════════════════════════════════════════════════════════ -->
@@ -225,7 +213,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="#FFFFFF"
android:background="?attr/colorSurface"
android:paddingHorizontal="12dp"
android:paddingTop="8dp"
android:paddingBottom="12dp">

View File

@@ -5,21 +5,20 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="#FFFFFF">
android:background="?attr/colorSurface">
<!-- Renderable receipt card (header grows to fill remaining space) -->
<LinearLayout
android:id="@+id/receiptCard"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="40dp"
android:orientation="vertical">
<!-- Green header — fills all space not taken by body -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_height="200dp"
android:background="@drawable/trx_success_bg">
<LinearLayout
@@ -36,7 +35,7 @@
android:textColor="#FFFFFF"
android:textSize="14sp"
android:letterSpacing="0.08"
android:layout_marginBottom="16dp" />
android:layout_marginBottom="22dp" />
<ImageView
android:layout_width="64dp"
@@ -50,9 +49,8 @@
android:text="SUCCESSFUL"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold"
android:letterSpacing="0.05"
android:layout_marginTop="12dp" />
android:layout_marginTop="18dp" />
</LinearLayout>
@@ -94,19 +92,18 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="12sp"
android:textColor="#1C1B1F"
android:textColor="#565656"
android:gravity="center"
android:maxLines="2" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginHorizontal="12dp"
android:text="→"
android:textSize="20sp"
android:textColor="#49454F" />
android:src="@drawable/ic_arrow_right"
android:contentDescription="@null" />
<LinearLayout
android:layout_width="0dp"
@@ -127,7 +124,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="12sp"
android:textColor="#1C1B1F"
android:textColor="#565656"
android:gravity="center"
android:maxLines="2" />
@@ -142,8 +139,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TOTAL AMOUNT"
android:textSize="11sp"
android:textColor="#49454F"
android:textSize="13sp"
android:textColor="#a0a2a1"
android:letterSpacing="0.08"
android:gravity="center"
android:paddingTop="10dp"
@@ -153,9 +150,8 @@
android:id="@+id/tvAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="28sp"
android:textStyle="bold"
android:textColor="#E65100"
android:textSize="34sp"
android:textColor="#f14f0f"
android:gravity="center"
android:paddingBottom="10dp" />
@@ -163,49 +159,49 @@
<!-- Reference # -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="13sp" android:textColor="#49454F" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#1C1B1F" />
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- To Account -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="13sp" android:textColor="#49454F" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#1C1B1F" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- To Bank -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="13sp" android:textColor="#49454F" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#1C1B1F" />
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- Transaction Date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="13sp" android:textColor="#49454F" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#1C1B1F" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- Value Date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="13sp" android:textColor="#49454F" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#1C1B1F" />
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<!-- Purpose -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="13sp" android:textColor="#49454F" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="13sp" android:textColor="#1C1B1F" />
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
</LinearLayout>
<!-- MIB footer -->
@@ -230,9 +226,8 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="MALDIVES ISLAMIC BANK"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="#49454F"
android:textSize="13sp"
android:textColor="#000000"
android:letterSpacing="0.05" />
</LinearLayout>
@@ -246,7 +241,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="#FFFFFF"
android:background="?attr/colorSurface"
android:paddingHorizontal="12dp"
android:paddingTop="8dp"
android:paddingBottom="12dp">

View File

@@ -19,11 +19,12 @@
android:paddingBottom="24dp"
android:gravity="center_horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🔒"
android:textSize="56sp"
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@drawable/ic_lock"
android:tint="?attr/colorPrimary"
android:contentDescription="@null"
android:layout_marginBottom="16dp" />
<TextView
@@ -255,73 +256,49 @@
</LinearLayout>
<!-- Step: Biometric prompt -->
<!-- Step: Already configured -->
<LinearLayout
android:id="@+id/viewBiometric"
android:id="@+id/viewConfigured"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:paddingHorizontal="24dp"
android:paddingTop="40dp"
android:paddingBottom="24dp"
android:gravity="center_horizontal"
android:visibility="gone">
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@drawable/ic_check_circle"
android:tint="?attr/colorPrimary"
android:contentDescription="@null"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🔐"
android:textSize="72sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/biometric_title"
android:text="@string/security_already_configured"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="12dp" />
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/biometric_desc"
android:text="@string/security_already_configured_desc"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:layout_marginBottom="16dp" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="14dp"
android:text="@string/biometric_security_note"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSecondaryContainer"
android:gravity="center" />
</com.google.android.material.card.MaterialCardView>
android:layout_marginBottom="32dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnEnableBiometrics"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="@string/enable_biometrics"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSkipBiometrics"
style="@style/Widget.Material3.Button.TextButton"
android:id="@+id/btnChangeLock"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/skip_biometrics" />
android:text="@string/settings_change_lock" />
</LinearLayout>

View File

@@ -1,226 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:id="@+id/settingsList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvLoginsTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_logins"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/loginsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddAccount"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="24dp"
android:text="@string/nav_add_account" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/theme"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/themeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeSystem"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_system" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeLight"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_light" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeDark"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_dark" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/language"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/languageToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangEnglish"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lang_english" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangDhivehi"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lang_dhivehi" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_security"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangeLock"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_change_lock" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_autolock"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/autolockToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolockOff"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_off" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock30s"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_30s" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock1m"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_1m" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock3m"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_3m" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock5m"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_5m" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_cache"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearCache"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_clear_cache" />
<LinearLayout
android:id="@+id/rowBiometrics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
android:paddingTop="8dp"
android:paddingBottom="8dp" />
</ScrollView>

View File

@@ -0,0 +1,123 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_navigation"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/navModeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavDrawer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_bottom" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/theme"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/themeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeSystem"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_system" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeLight"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_light" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeDark"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_dark" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/language"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/languageToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangEnglish"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lang_english" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangDhivehi"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/lang_dhivehi" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvLoginsTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_logins"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/loginsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddAccount"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/nav_add_account" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,205 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_security"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangeLock"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_change_lock" />
<LinearLayout
android:id="@+id/rowBiometrics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvBiometricsHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics_unavailable"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_unlock"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_transfer"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometricsTransfer"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_autolock"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/autolockToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolockOff"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_off" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock30s"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_30s" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock1m"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_1m" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock3m"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_3m" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAutolock5m"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/autolock_5m" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_privacy"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_block_screenshots"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_block_screenshots_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBlockScreenshots"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_cache"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnClearCache"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/settings_clear_cache" />
</LinearLayout>
</ScrollView>

View File

@@ -12,28 +12,23 @@
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- From label + account dropdown -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:text="@string/transfer_label_from"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- From account dropdown -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilFrom"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/transfer_from"
android:layout_marginBottom="16dp">
android:layout_marginBottom="8dp">
<AutoCompleteTextView
android:id="@+id/actvFrom"
@@ -45,7 +40,88 @@
</com.google.android.material.textfield.TextInputLayout>
<!-- To field row: input + pick contact button -->
<!-- Selected source account info card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardFromInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginBottom="16dp"
app:cardCornerRadius="4dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorPrimary">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="4dp"
android:paddingVertical="12dp"
android:gravity="center_vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivFromPhoto"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvFromAccountName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvFromAccountNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:fontFamily="monospace" />
<TextView
android:id="@+id/tvFromAccountDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<ImageButton
android:id="@+id/btnClearFromInfo"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/transfer_clear_recipient" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- To label + field row: input + pick contact button -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="6dp"
android:text="@string/transfer_label_to"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -79,7 +155,8 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:icon="@drawable/ic_contacts"
android:contentDescription="@string/transfer_pick_contact" />
android:contentDescription="@string/transfer_pick_contact"
android:tooltipText="@string/transfer_pick_contact" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanQr"
@@ -88,7 +165,8 @@
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
app:icon="@drawable/ic_qr_scan"
android:contentDescription="@string/transfer_scan_qr" />
android:contentDescription="@string/transfer_scan_qr"
android:tooltipText="@string/transfer_scan_qr" />
</LinearLayout>
@@ -98,6 +176,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_marginTop="8dp"
app:cardCornerRadius="4dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
@@ -155,6 +234,66 @@
</com.google.android.material.card.MaterialCardView>
<!-- Service selector: invisible to reserve space, prevents amount/remarks from shifting -->
<LinearLayout
android:id="@+id/layoutServiceSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp"
android:visibility="invisible">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/transfer_select_service"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupService"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.chip.Chip
android:id="@+id/chipDhiraaguReload"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Dhiraagu Reload"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipDhiraaguBill"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Dhiraagu Bill Pay"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipRaastas"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Raastas"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipOoredooBill"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ooredoo Bill Pay"
android:visibility="gone" />
</com.google.android.material.chip.ChipGroup>
</LinearLayout>
<View
android:id="@+id/spacerTo"
android:layout_width="match_parent"
@@ -203,10 +342,6 @@
android:layout_height="wrap_content"
android:text="@string/transfer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>

Some files were not shown because too many files have changed in this diff Show More