diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..2a8a017a0 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,49 @@ +{ + "permissions": { + "allow": [ + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(git remote:*)", + "Bash(git tag:*)", + "Bash(git blame:*)", + "Bash(git stash list:*)", + "Bash(ls:*)", + "Bash(find:*)", + "Bash(cat:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(diff:*)", + "Bash(echo:*)", + "Bash(pwd)", + "Bash(which:*)", + "Bash(whoami)", + "Bash(env)", + "Bash(printenv:*)", + "Bash(sort:*)", + "Bash(uniq:*)", + "Bash(tr:*)", + "Bash(cut:*)", + "Bash(awk:*)", + "Bash(sed:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(xargs:*)", + "Bash(tree:*)", + "Bash(file:*)", + "Bash(basename:*)", + "Bash(dirname:*)", + "Bash(realpath:*)", + "Bash(stat:*)", + "Bash(date:*)", + "Bash(uname:*)", + "Read", + "Glob", + "Grep" + ], + "deny": [] + } +} diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..8242c5ce1 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,5 @@ +# Optional: allow live web search without prompts. +web_search = "live" + +[sandbox_workspace_write] +network_access = true diff --git a/.codex/rules/allow_gh.rules b/.codex/rules/allow_gh.rules new file mode 100644 index 000000000..fd2202b1d --- /dev/null +++ b/.codex/rules/allow_gh.rules @@ -0,0 +1,11 @@ +# Allow all GitHub CLI commands to run outside the sandbox. +prefix_rule( + pattern = ["gh"], + decision = "allow", + justification = "Allow all readonly gh commands without prompts.", + match = [ + "gh auth status", + "gh pr view 1", + "gh api repos/owner/repo/pulls/1", + ], +) diff --git a/.codex/rules/allow_make.rules b/.codex/rules/allow_make.rules new file mode 100644 index 000000000..1d98a2276 --- /dev/null +++ b/.codex/rules/allow_make.rules @@ -0,0 +1,26 @@ +# Allow Makefile commands to run without prompts. +prefix_rule( + pattern = ["make"], + decision = "allow", + justification = "Allow repository Makefile commands without prompts.", + match = [ + "make compile", + "make test", + "make test_all", + "make test_list", + "make test_core_swift", + "make test_tron_swift_package", + "make test_tkcore_package", + "make test_tklocalize_package", + "make test_tkchart_package", + "make test_core_components", + "make test_keeper_core", + "make test_wallet_core", + "make test_tron_swift", + "make test_tkcryptokit", + "make test_tkcore", + "make test_tklocalize", + "make test_tkchart", + "make test_package PACKAGE=./LocalPackages/core-swift SCHEME=WalletCore TEST_ONLY=WalletCoreTests", + ], +) diff --git a/.codex/rules/allow_skill_scripts.rules b/.codex/rules/allow_skill_scripts.rules new file mode 100644 index 000000000..91aa8fae4 --- /dev/null +++ b/.codex/rules/allow_skill_scripts.rules @@ -0,0 +1,30 @@ +# Allow running skill scripts without prompts. +prefix_rule( + pattern = ["python3", ".codex/skills/pr/scripts/get_pr_data.py"], + decision = "allow", + justification = "Allow PR skill script without prompts.", + match = [ + "python3 .codex/skills/pr/scripts/get_pr_data.py ensure-setup", + "python3 .codex/skills/pr/scripts/get_pr_data.py get-pr 354", + ], +) + +prefix_rule( + pattern = ["python3", ".codex/skills/linear/scripts/get_linear_task.py"], + decision = "allow", + justification = "Allow Linear skill script without prompts.", + match = [ + "python3 .codex/skills/linear/scripts/get_linear_task.py get-task IOS-562", + "python3 .codex/skills/linear/scripts/get_linear_task.py get-task IOS-366", + ], +) + +prefix_rule( + pattern = ["python3", ".codex/skills/tondocs/scripts/tondocs.py"], + decision = "allow", + justification = "Allow TON docs skill script without prompts.", + match = [ + "python3 .codex/skills/tondocs/scripts/tondocs.py sync", + "python3 .codex/skills/tondocs/scripts/tondocs.py search jetton", + ], +) diff --git a/.codex/skills/figma-implement-design/LICENSE.txt b/.codex/skills/figma-implement-design/LICENSE.txt new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/.codex/skills/figma-implement-design/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/.codex/skills/figma-implement-design/SKILL.md b/.codex/skills/figma-implement-design/SKILL.md new file mode 100644 index 000000000..8581cfc2a --- /dev/null +++ b/.codex/skills/figma-implement-design/SKILL.md @@ -0,0 +1,264 @@ +--- +name: "figma-implement-design" +description: "Translate Figma nodes into production-ready code with 1:1 visual fidelity using the Figma MCP workflow (design context, screenshots, assets, and project-convention translation). Trigger when the user provides Figma URLs or node IDs, or asks to implement designs or components that must match Figma specs. Requires a working Figma MCP server connection." +--- + + +# Implement Design + +## Overview + +This skill provides a structured workflow for translating Figma designs into production-ready code with pixel-perfect accuracy. It ensures consistent integration with the Figma MCP server, proper use of design tokens, and 1:1 visual parity with designs. + +## Prerequisites + +- Figma MCP server must be connected and accessible +- User must provide a Figma URL in the format: `https://figma.com/design/:fileKey/:fileName?node-id=1-2` + - `:fileKey` is the file key + - `1-2` is the node ID (the specific component or frame to implement) +- **OR** when using `figma-desktop` MCP: User can select a node directly in the Figma desktop app (no URL required) +- Project should have an established design system or component library (preferred) + +## Required Workflow + +**Follow these steps in order. Do not skip steps.** + +### Step 0: Set up Figma MCP (if not already configured) + +If any MCP call fails because Figma MCP is not connected, pause and set it up: + +1. Add the Figma MCP: + - `codex mcp add figma --url https://mcp.figma.com/mcp` +2. Enable remote MCP client: + - Set `[features].rmcp_client = true` in `config.toml` **or** run `codex --enable rmcp_client` +3. Log in with OAuth: + - `codex mcp login figma` + +After successful login, the user will have to restart codex. You should finish your answer and tell them so when they try again they can continue with Step 1. + +### Step 1: Get Node ID + +#### Option A: Parse from Figma URL + +When the user provides a Figma URL, extract the file key and node ID to pass as arguments to MCP tools. + +**URL format:** `https://figma.com/design/:fileKey/:fileName?node-id=1-2` + +**Extract:** + +- **File key:** `:fileKey` (the segment after `/design/`) +- **Node ID:** `1-2` (the value of the `node-id` query parameter) + +**Note:** When using the local desktop MCP (`figma-desktop`), `fileKey` is not passed as a parameter to tool calls. The server automatically uses the currently open file, so only `nodeId` is needed. + +**Example:** + +- URL: `https://figma.com/design/kL9xQn2VwM8pYrTb4ZcHjF/DesignSystem?node-id=42-15` +- File key: `kL9xQn2VwM8pYrTb4ZcHjF` +- Node ID: `42-15` + +#### Option B: Use Current Selection from Figma Desktop App (figma-desktop MCP only) + +When using the `figma-desktop` MCP and the user has NOT provided a URL, the tools automatically use the currently selected node from the open Figma file in the desktop app. + +**Note:** Selection-based prompting only works with the `figma-desktop` MCP server. The remote server requires a link to a frame or layer to extract context. The user must have the Figma desktop app open with a node selected. + +### Step 2: Fetch Design Context + +Run `get_design_context` with the extracted file key and node ID. + +``` +get_design_context(fileKey=":fileKey", nodeId="1-2") +``` + +This provides the structured data including: + +- Layout properties (Auto Layout, constraints, sizing) +- Typography specifications +- Color values and design tokens +- Component structure and variants +- Spacing and padding values + +**If the response is too large or truncated:** + +1. Run `get_metadata(fileKey=":fileKey", nodeId="1-2")` to get the high-level node map +2. Identify the specific child nodes needed from the metadata +3. Fetch individual child nodes with `get_design_context(fileKey=":fileKey", nodeId=":childNodeId")` + +### Step 3: Capture Visual Reference + +Run `get_screenshot` with the same file key and node ID for a visual reference. + +``` +get_screenshot(fileKey=":fileKey", nodeId="1-2") +``` + +This screenshot serves as the source of truth for visual validation. Keep it accessible throughout implementation. + +### Step 4: Download Required Assets + +Download any assets (images, icons, SVGs) returned by the Figma MCP server. + +**IMPORTANT:** Follow these asset rules: + +- If the Figma MCP server returns a `localhost` source for an image or SVG, use that source directly +- DO NOT import or add new icon packages - all assets should come from the Figma payload +- DO NOT use or create placeholders if a `localhost` source is provided +- Assets are served through the Figma MCP server's built-in assets endpoint + +### Step 5: Translate to Project Conventions + +Translate the Figma output into this project's framework, styles, and conventions. + +**Key principles:** + +- Treat the Figma MCP output (typically React + Tailwind) as a representation of design and behavior, not as final code style +- Replace Tailwind utility classes with the project's preferred utilities or design system tokens +- Reuse existing components (buttons, inputs, typography, icon wrappers) instead of duplicating functionality +- Use the project's color system, typography scale, and spacing tokens consistently +- Respect existing routing, state management, and data-fetch patterns + +### Step 6: Achieve 1:1 Visual Parity + +Strive for pixel-perfect visual parity with the Figma design. + +**Guidelines:** + +- Prioritize Figma fidelity to match designs exactly +- Avoid hardcoded values - use design tokens from Figma where available +- When conflicts arise between design system tokens and Figma specs, prefer design system tokens but adjust spacing or sizes minimally to match visuals +- Follow WCAG requirements for accessibility +- Add component documentation as needed + +### Step 7: Validate Against Figma + +Before marking complete, validate the final UI against the Figma screenshot. + +**Validation checklist:** + +- [ ] Layout matches (spacing, alignment, sizing) +- [ ] Typography matches (font, size, weight, line height) +- [ ] Colors match exactly +- [ ] Interactive states work as designed (hover, active, disabled) +- [ ] Responsive behavior follows Figma constraints +- [ ] Assets render correctly +- [ ] Accessibility standards met + +## Implementation Rules + +### Component Organization + +- Place UI components in the project's designated design system directory +- Follow the project's component naming conventions +- Avoid inline styles unless truly necessary for dynamic values + +### Design System Integration + +- ALWAYS use components from the project's design system when possible +- Map Figma design tokens to project design tokens +- When a matching component exists, extend it rather than creating a new one +- Document any new components added to the design system + +### Code Quality + +- Avoid hardcoded values - extract to constants or design tokens +- Keep components composable and reusable +- Add TypeScript types for component props +- Include JSDoc comments for exported components + +## Examples + +### Example 1: Implementing a Button Component + +User says: "Implement this Figma button component: https://figma.com/design/kL9xQn2VwM8pYrTb4ZcHjF/DesignSystem?node-id=42-15" + +**Actions:** + +1. Parse URL to extract fileKey=`kL9xQn2VwM8pYrTb4ZcHjF` and nodeId=`42-15` +2. Run `get_design_context(fileKey="kL9xQn2VwM8pYrTb4ZcHjF", nodeId="42-15")` +3. Run `get_screenshot(fileKey="kL9xQn2VwM8pYrTb4ZcHjF", nodeId="42-15")` for visual reference +4. Download any button icons from the assets endpoint +5. Check if project has existing button component +6. If yes, extend it with new variant; if no, create new component using project conventions +7. Map Figma colors to project design tokens (e.g., `primary-500`, `primary-hover`) +8. Validate against screenshot for padding, border radius, typography + +**Result:** Button component matching Figma design, integrated with project design system. + +### Example 2: Building a Dashboard Layout + +User says: "Build this dashboard: https://figma.com/design/pR8mNv5KqXzGwY2JtCfL4D/Dashboard?node-id=10-5" + +**Actions:** + +1. Parse URL to extract fileKey=`pR8mNv5KqXzGwY2JtCfL4D` and nodeId=`10-5` +2. Run `get_metadata(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId="10-5")` to understand the page structure +3. Identify main sections from metadata (header, sidebar, content area, cards) and their child node IDs +4. Run `get_design_context(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId=":childNodeId")` for each major section +5. Run `get_screenshot(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId="10-5")` for the full page +6. Download all assets (logos, icons, charts) +7. Build layout using project's layout primitives +8. Implement each section using existing components where possible +9. Validate responsive behavior against Figma constraints + +**Result:** Complete dashboard matching Figma design with responsive layout. + +## Best Practices + +### Always Start with Context + +Never implement based on assumptions. Always fetch `get_design_context` and `get_screenshot` first. + +### Incremental Validation + +Validate frequently during implementation, not just at the end. This catches issues early. + +### Document Deviations + +If you must deviate from the Figma design (e.g., for accessibility or technical constraints), document why in code comments. + +### Reuse Over Recreation + +Always check for existing components before creating new ones. Consistency across the codebase is more important than exact Figma replication. + +### Design System First + +When in doubt, prefer the project's design system patterns over literal Figma translation. + +## Common Issues and Solutions + +### Issue: Figma output is truncated + +**Cause:** The design is too complex or has too many nested layers to return in a single response. +**Solution:** Use `get_metadata` to get the node structure, then fetch specific nodes individually with `get_design_context`. + +### Issue: Design doesn't match after implementation + +**Cause:** Visual discrepancies between the implemented code and the original Figma design. +**Solution:** Compare side-by-side with the screenshot from Step 3. Check spacing, colors, and typography values in the design context data. + +### Issue: Assets not loading + +**Cause:** The Figma MCP server's assets endpoint is not accessible or the URLs are being modified. +**Solution:** Verify the Figma MCP server's assets endpoint is accessible. The server serves assets at `localhost` URLs. Use these directly without modification. + +### Issue: Design token values differ from Figma + +**Cause:** The project's design system tokens have different values than those specified in the Figma design. +**Solution:** When project tokens differ from Figma values, prefer project tokens for consistency but adjust spacing/sizing to maintain visual fidelity. + +## Understanding Design Implementation + +The Figma implementation workflow establishes a reliable process for translating designs to code: + +**For designers:** Confidence that implementations will match their designs with pixel-perfect accuracy. +**For developers:** A structured approach that eliminates guesswork and reduces back-and-forth revisions. +**For teams:** Consistent, high-quality implementations that maintain design system integrity. + +By following this workflow, you ensure that every Figma design is implemented with the same level of care and attention to detail. + +## Additional Resources + +- [Figma MCP Server Documentation](https://developers.figma.com/docs/figma-mcp-server/) +- [Figma MCP Server Tools and Prompts](https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/) +- [Figma Variables and Design Tokens](https://help.figma.com/hc/en-us/articles/15339657135383-Guide-to-variables-in-Figma) diff --git a/.codex/skills/figma-implement-design/agents/openai.yaml b/.codex/skills/figma-implement-design/agents/openai.yaml new file mode 100644 index 000000000..04fe2b0bb --- /dev/null +++ b/.codex/skills/figma-implement-design/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: "Figma Implement Design" + short_description: "Turn Figma designs into production-ready code" + icon_small: "./assets/figma-small.svg" + icon_large: "./assets/figma.png" + default_prompt: "Implement this Figma design in this codebase, matching layout, states, and responsive behavior." + +dependencies: + tools: + - type: "mcp" + value: "figma" + description: "Figma MCP server" + transport: "streamable_http" + url: "https://mcp.figma.com/mcp" diff --git a/.codex/skills/figma-implement-design/assets/figma-small.svg b/.codex/skills/figma-implement-design/assets/figma-small.svg new file mode 100644 index 000000000..b2c61bae7 --- /dev/null +++ b/.codex/skills/figma-implement-design/assets/figma-small.svg @@ -0,0 +1,3 @@ + + + diff --git a/.codex/skills/figma-implement-design/assets/figma.png b/.codex/skills/figma-implement-design/assets/figma.png new file mode 100644 index 000000000..ebeedbf5d Binary files /dev/null and b/.codex/skills/figma-implement-design/assets/figma.png differ diff --git a/.codex/skills/figma-implement-design/assets/icon.svg b/.codex/skills/figma-implement-design/assets/icon.svg new file mode 100644 index 000000000..5184f7cf2 --- /dev/null +++ b/.codex/skills/figma-implement-design/assets/icon.svg @@ -0,0 +1,28 @@ + + + + + + + diff --git a/.codex/skills/linear/LICENSE.txt b/.codex/skills/linear/LICENSE.txt new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/.codex/skills/linear/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/.codex/skills/linear/SKILL.md b/.codex/skills/linear/SKILL.md new file mode 100644 index 000000000..2e6352faa --- /dev/null +++ b/.codex/skills/linear/SKILL.md @@ -0,0 +1,68 @@ +--- +name: linear +description: Retrieve Linear task context via Linear MCP for implementation work. Use when the developer needs requirements, scope, or acceptance criteria from a ticket. +metadata: + short-description: Linear MCP task context for coding +--- + +# Linear + +## Overview + +This skill uses the Linear MCP server to gather issue context for developers implementing tasks. It is context-oriented and read-only by default. + +## Prerequisites +- Linear MCP server must be connected and accessible via OAuth +- Confirm access to the relevant Linear workspace and teams + +## Required Workflow + +**Follow these steps in order. Do not skip steps.** + +### Step 0: Set up Linear MCP (if not already configured) + +If any MCP call fails because Linear MCP is not connected, pause and set it up: + +1. Add the Linear MCP: + - `codex mcp add linear --url https://mcp.linear.app/mcp` +2. Enable remote MCP client: + - Set `[features] rmcp_client = true` in `config.toml` or run `codex --enable rmcp_client` +3. Log in with OAuth: + - `codex mcp login linear` + +After successful login, the user must restart Codex. Finish the answer and instruct them to restart and retry. + +**Windows/WSL note:** If connection errors appear on Windows, configure Linear MCP via WSL: +```json +{"mcpServers":{"linear":{"command":"wsl","args":["npx","-y","mcp-remote","https://mcp.linear.app/sse","--transport","sse-only"]}}} +``` + +### Step 1 +Clarify the implementation goal and scope for the task. Confirm relevant identifiers (issue ID, team key, project if needed). + +### Step 2 +Use read-focused Linear MCP tools to collect context: +- `get_issue`, `list_issues`, `list_my_issues` +- `list_issue_statuses`, `list_issue_labels` +- `get_project`, `list_projects` +- `get_team`, `list_teams`, `list_users` +- `list_documents`, `get_document`, `search_documentation` +- `list_comments`, `list_cycles` + +### Step 3: Build implementation context +From retrieved data, extract and present: +- Problem statement +- Scope and out-of-scope boundaries +- Acceptance criteria / definition of done +- UX, API, and technical constraints +- Dependencies, risks, and blockers +- Open questions and assumptions that must be clarified before coding + +### Step 4 +Summarize findings for the developer and map them to an implementation plan and test plan. + +## Guardrails + +- Do not create or update Linear entities unless the user explicitly asks. +- Do not run project-management workflows (triage, planning, redistribution) by default. +- Prefer read calls first; if write actions are requested later, confirm intent and scope before proceeding. diff --git a/.codex/skills/linear/agents/openai.yaml b/.codex/skills/linear/agents/openai.yaml new file mode 100644 index 000000000..20514a34a --- /dev/null +++ b/.codex/skills/linear/agents/openai.yaml @@ -0,0 +1,14 @@ +interface: + display_name: "Linear" + short_description: "Manage Linear issues in Codex" + icon_small: "./assets/linear-small.svg" + icon_large: "./assets/linear.png" + default_prompt: "Use Linear context to triage or update relevant issues for this task, with clear next actions." + +dependencies: + tools: + - type: "mcp" + value: "linear" + description: "Linear MCP server" + transport: "streamable_http" + url: "https://mcp.linear.app/mcp" diff --git a/.codex/skills/linear/assets/linear-small.svg b/.codex/skills/linear/assets/linear-small.svg new file mode 100644 index 000000000..e2fbc5109 --- /dev/null +++ b/.codex/skills/linear/assets/linear-small.svg @@ -0,0 +1,5 @@ + + + diff --git a/.codex/skills/linear/assets/linear.png b/.codex/skills/linear/assets/linear.png new file mode 100644 index 000000000..eff45583b Binary files /dev/null and b/.codex/skills/linear/assets/linear.png differ diff --git a/.codex/skills/pr/SKILL.md b/.codex/skills/pr/SKILL.md new file mode 100644 index 000000000..d35f5125e --- /dev/null +++ b/.codex/skills/pr/SKILL.md @@ -0,0 +1,37 @@ +--- +name: pr +description: toolkit for llm-friendly interaction with GitHub pull requests; using this skill the LLM can fetch PR data and analyze it +metadata: + short-description: LLM-friendly interactions with GitHub PRs +--- + +# Import pull request data as markdown + +Use this skill to fetch PR metadata and file diffs into LLM-friendly Markdown files. + +## Prereqs +- Ensure `gh` is installed and `gh auth login` has been completed. +- Confirm setup: `python3 .codex/skills/pr/scripts/get_pr_data.py ensure-setup` returns zero exit code. + +## Usage +Fetch a PR by number: +```bash +python3 .codex/skills/pr/scripts/get_pr_data.py get-pr 354 +``` + +Fetch from a specific repo: +```bash +python3 .codex/skills/pr/scripts/get_pr_data.py get-pr 354 --repo tonkeeper/ios_private +``` + +Write to a custom output directory: +```bash +python3 .codex/skills/pr/scripts/get_pr_data.py get-pr 354 --output /tmp/pr-354 +``` + +## Output +- Default directory: `.context/tasks//` (relative to repo root) +- One markdown file per diff (`0.md`, `1.md`, ...) with keys as headings and the `patch` field in a `diff` code block. +- `sha.json` with base commit metadata is written to the same directory. +- `.env` is loaded from the repo root if present. + diff --git a/.codex/skills/pr/scripts/get_pr_data.py b/.codex/skills/pr/scripts/get_pr_data.py new file mode 100644 index 000000000..ee507e726 --- /dev/null +++ b/.codex/skills/pr/scripts/get_pr_data.py @@ -0,0 +1,399 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import shutil +import subprocess +import sys + +DEFAULT_REPO = "tonkeeper/ios_private" + +def _repo_root(): + current = os.path.abspath(os.path.dirname(__file__)) + while True: + if os.path.isdir(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def load_env_file(path=None): + if path is None: + root = _repo_root() + path = os.path.join(root, ".env") if root else ".env" + if not os.path.isfile(path): + return + with open(path, "r", encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def run_cmd(cmd): + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Command failed") + return result.stdout + + +def gh_json(args, repo=None): + cmd = ["gh"] + args + if repo and "-R" not in cmd and "--repo" not in cmd: + cmd += ["-R", repo] + output = run_cmd(cmd) + return json.loads(output) + + +def gh_text(args, repo=None): + cmd = ["gh"] + args + if repo and "-R" not in cmd and "--repo" not in cmd: + cmd += ["-R", repo] + return run_cmd(cmd) + + +def ensure_setup(): + errors = [] + if not shutil.which("gh"): + errors.append("gh is required") + if not shutil.which("python3") and not shutil.which("python"): + errors.append("python3 is required") + if errors: + for error in errors: + print(f"Setup error: {error}") + sys.exit(1) + try: + run_cmd(["gh", "auth", "status", "-h", "github.com"]) + except RuntimeError: + print("Setup error: gh is not authenticated for github.com") + sys.exit(1) + print("Setup OK.") + + +def fetch_pr(repo, number): + fields = [ + "number", + "title", + "body", + "author", + "url", + "createdAt", + "updatedAt", + "baseRefName", + "headRefName", + "additions", + "deletions", + "changedFiles", + "labels", + ] + args = ["pr", "view", str(number), "--json", ",".join(fields)] + return gh_json(args, repo=repo) + + +def fetch_pr_files(repo, number): + args = [ + "api", + "-H", + "Accept: application/vnd.github+json", + f"repos/{repo}/pulls/{number}/files", + "--paginate", + "--jq", + ".[]", + ] + raw = gh_text(args) + return _parse_json_lines(raw) + + +def fetch_pr_commits(repo, number): + args = [ + "api", + "-H", + "Accept: application/vnd.github+json", + f"repos/{repo}/pulls/{number}/commits", + "--paginate", + "--jq", + ".[]", + ] + raw = gh_text(args) + return _parse_json_lines(raw) + + +def fetch_issue_comments(repo, number): + args = [ + "api", + "-H", + "Accept: application/vnd.github+json", + f"repos/{repo}/issues/{number}/comments", + "--paginate", + "--jq", + ".[]", + ] + raw = gh_text(args) + return _parse_json_lines(raw) + + +def fetch_review_comments(repo, number): + args = [ + "api", + "-H", + "Accept: application/vnd.github+json", + f"repos/{repo}/pulls/{number}/comments", + "--paginate", + "--jq", + ".[]", + ] + raw = gh_text(args) + return _parse_json_lines(raw) + + +def fetch_pr_base_sha(repo, number): + args = [ + "api", + "-H", + "Accept: application/vnd.github+json", + f"repos/{repo}/pulls/{number}", + "--jq", + ".base.sha", + ] + sha = gh_text(args).strip() + return sha or None + + +def fetch_commit(repo, sha): + if not sha: + return None + args = [ + "api", + "-H", + "Accept: application/vnd.github+json", + f"repos/{repo}/commits/{sha}", + ] + return gh_json(args) + + +def _parse_json_lines(raw): + items = [] + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + try: + items.append(json.loads(line)) + except json.JSONDecodeError: + continue + return items + + +def _stringify_value(value): + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + return str(value) + + +def _format_markdown_list(value, indent=0): + indent_str = " " * indent + if isinstance(value, dict): + lines = [] + for key, item in value.items(): + if isinstance(item, (dict, list)): + lines.append(f"{indent_str}- {key}:") + lines.extend(_format_markdown_list(item, indent + 1)) + else: + lines.append(f"{indent_str}- {key}: {_stringify_value(item)}") + return lines + if isinstance(value, list): + lines = [] + for item in value: + if isinstance(item, (dict, list)): + lines.append(f"{indent_str}-") + lines.extend(_format_markdown_list(item, indent + 1)) + else: + lines.append(f"{indent_str}- {_stringify_value(item)}") + return lines + return [f"{indent_str}- {_stringify_value(value)}"] + + +def _render_markdown(item): + if item is None: + return "# error\r\n\r\nNo file data returned.\r\n" + if not isinstance(item, dict): + return f"# result\r\n\r\n{_stringify_value(item)}\r\n" + sections = [] + for key, value in item.items(): + sections.append(f"# {key}\r\n") + if key == "patch": + if value: + sections.append("```diff\r\n") + sections.append(f"{value}\r\n") + sections.append("```\r\n") + else: + sections.append("null\r\n") + elif isinstance(value, (dict, list)): + lines = _format_markdown_list(value) + sections.append("\r\n".join(lines) + "\r\n") + else: + sections.append(f"{_stringify_value(value)}\r\n") + content = "\r\n".join(sections).rstrip("\r\n") + return content + + +def _render_json(payload): + output = json.dumps(payload, indent=2, ensure_ascii=False) + output = output.replace("\n", "\r\n").rstrip("\r\n") + return output + + +def _default_output_dir(pr_number): + repo_root = _repo_root() or os.path.dirname(os.path.abspath(__file__)) + return os.path.join(repo_root, ".context", "pull-requests", str(pr_number)) + + +def write_output(files, pr_number, output_path=None): + if output_path: + output_dir = output_path + else: + output_dir = _default_output_dir(pr_number) + os.makedirs(output_dir, exist_ok=True) + for index, item in enumerate(files): + filename = f"{index}.md" + final_path = os.path.join(output_dir, filename) + output = _render_markdown(item) + with open(final_path, "w", encoding="utf-8", newline="") as handle: + handle.write(output) + print(final_path) + + +def write_sha(commit, output_dir): + if not commit: + return + payload = { + "sha": commit.get("sha"), + "url": commit.get("html_url"), + "author": { + "name": commit.get("commit", {}).get("author", {}).get("name"), + "email": commit.get("commit", {}).get("author", {}).get("email"), + "date": commit.get("commit", {}).get("author", {}).get("date"), + "login": (commit.get("author") or {}).get("login"), + }, + "message": commit.get("commit", {}).get("message"), + } + final_path = os.path.join(output_dir, "sha.json") + output = _render_json(payload) + with open(final_path, "w", encoding="utf-8", newline="") as handle: + handle.write(output) + print(final_path) + + +def write_commits(commits, output_dir): + payload = [] + for commit in commits: + payload.append( + { + "sha": commit.get("sha"), + "url": commit.get("html_url"), + "author": { + "login": (commit.get("author") or {}).get("login"), + "name": commit.get("commit", {}).get("author", {}).get("name"), + "email": commit.get("commit", {}).get("author", {}).get("email"), + "date": commit.get("commit", {}).get("author", {}).get("date"), + }, + "committer": { + "login": (commit.get("committer") or {}).get("login"), + "name": commit.get("commit", {}).get("committer", {}).get("name"), + "email": commit.get("commit", {}).get("committer", {}).get("email"), + "date": commit.get("commit", {}).get("committer", {}).get("date"), + }, + "message": commit.get("commit", {}).get("message"), + } + ) + final_path = os.path.join(output_dir, "commits.json") + output = _render_json(payload) + with open(final_path, "w", encoding="utf-8", newline="") as handle: + handle.write(output) + print(final_path) + + +def _reduce_issue_comment(comment): + return { + "id": comment.get("id"), + "url": comment.get("html_url") or comment.get("url"), + "user": (comment.get("user") or {}).get("login"), + "body": comment.get("body"), + "created_at": comment.get("created_at"), + "updated_at": comment.get("updated_at"), + } + + +def _reduce_review_comment(comment): + return { + "id": comment.get("id"), + "url": comment.get("html_url") or comment.get("url"), + "user": (comment.get("user") or {}).get("login"), + "body": comment.get("body"), + "path": comment.get("path"), + "line": comment.get("line"), + "original_line": comment.get("original_line"), + "side": comment.get("side"), + "created_at": comment.get("created_at"), + "updated_at": comment.get("updated_at"), + } + + +def write_comments(issue_comments, review_comments, output_dir): + payload = { + "issue_comments": [_reduce_issue_comment(comment) for comment in issue_comments], + "review_comments": [_reduce_review_comment(comment) for comment in review_comments], + } + final_path = os.path.join(output_dir, "comments.json") + output = _render_json(payload) + with open(final_path, "w", encoding="utf-8", newline="") as handle: + handle.write(output) + print(final_path) + + +def main(): + load_env_file() + parser = argparse.ArgumentParser(description="Fetch PR metadata and file list") + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("ensure-setup", help="Verify dependencies and env vars") + + get_parser = subparsers.add_parser("get-pr", help="Fetch PR metadata and files") + get_parser.add_argument("pr_number", type=int, help="PR number") + get_parser.add_argument("--repo", default=DEFAULT_REPO, help="GitHub repo (owner/name)") + get_parser.add_argument("--output", help="Write JSON output to file") + + args = parser.parse_args() + + if args.command == "ensure-setup": + ensure_setup() + return + + output_dir = args.output or _default_output_dir(args.pr_number) + os.makedirs(output_dir, exist_ok=True) + + files = fetch_pr_files(args.repo, args.pr_number) + write_output(files, args.pr_number, output_path=output_dir) + commits = fetch_pr_commits(args.repo, args.pr_number) + write_commits(commits, output_dir) + issue_comments = fetch_issue_comments(args.repo, args.pr_number) + review_comments = fetch_review_comments(args.repo, args.pr_number) + write_comments(issue_comments, review_comments, output_dir) + base_sha = fetch_pr_base_sha(args.repo, args.pr_number) + commit = fetch_commit(args.repo, base_sha) + write_sha(commit, output_dir) + + +if __name__ == "__main__": + main() diff --git a/.codex/skills/tk-impact-analisys/README.md b/.codex/skills/tk-impact-analisys/README.md new file mode 100644 index 000000000..33affee4e --- /dev/null +++ b/.codex/skills/tk-impact-analisys/README.md @@ -0,0 +1,151 @@ +# tk-impact-analisys + +Compares the current branch with the relevant release branch and suggests test cases from the regress set based on the code changes + +## What It Does + +The skill: + +- detects the current branch in the selected source repository +- fetches release branches from `origin` so the latest release candidates are available locally +- discovers release branches matching `release/YY.MM.Iterator` or `release/YY.MM.Iterator-hotfixiterator` +- compares the current branch with the right release branch automatically: + - current branch is the latest release -> compare with the previous release + - current branch is an older release -> compare with the latest release + - current branch is not a release branch -> compare with the latest release +- reads the platform regression source from `regress.txt` +- analyzes changed code and maps it to relevant regression coverage +- extracts module and feature names from changed paths and uses them as search terms +- highlights changed files that did not match a known QA area +- shows module-aware search terms for unmatched files and flags uncovered functionality explicitly +- recommends which existing regression checks to run +- suggests missing regression blocks to add +- suggests extra exploratory checks based on the diff +- generates: + - a Markdown report + - a TXT test collection for the specific run + +## Supported Platforms + +- `iOS` +- `Android` +- `Web` + +By default, the skill: + +- uses the nearest parent git repository containing the skill as the source repository for comparison +- looks for platform regression data under `sources//...` +- uses the English regression source when an English variant exists, for example `sources/iOS/regress_EN.txt` + +## Required Inputs + +- `PLATFORM`: `iOS`, `Android`, or `Web` + +Optional: + +- `SOURCE_REPOSITORY`: custom source repository used for git comparison +- `BASE_BRANCH`: override the current checked out branch +- `RELEASE_BRANCH`: override the auto-selected release branch +- `REPO_PATH`: legacy alias for `SOURCE_REPOSITORY` +- `REGRESS_PATH`: custom `regress.txt` path +- `REPORT_PATH`: custom Markdown report path +- `TESTS_COLLECTION_PATH`: custom TXT test collection path + +## Default Output Paths + +- report template: `report.md` +- tests template: `test-collection-template.txt` +- run report: `reports///report.md` +- run tests collection: `reports///test-collection.txt` +- raw git bundle: `reports///raw-git-data.txt` + +## How To Run + +### In Codex + +Example with default Android paths: + +```text +Use $tk-impact-analisys to analyze impact for Android. Detect the current branch, compare it with the correct release branch automatically, use the current source repository for git comparison, and use the default regress.txt path. +``` + +Example with custom paths: + +```text +Use $tk-impact-analisys to analyze impact for PLATFORM=. +Detect the current branch and compare it with the correct release branch automatically. +Use SOURCE_REPOSITORY=/path/to/web-repo and REGRESS_PATH=/path/to/web/regress.txt. +``` + +### Locally + +You can run the local CLI instead of using Codex for the repeatable parts of the workflow: + +```bash +python3 .codex/skills/tk-impact-analisys/scripts/tk_impact_analysis.py --platform Andoid --write-raw +``` + +Useful options: + +- `--base-branch feature/some-branch` +- `--release-branch release/26.02.1` +- `--source-repository /path/to/repo` +- `--regress-path /path/to/regress.txt` +- `--report-path /custom/report.md` +- `--tests-collection-path /custom/test-collection.txt` +- `--date 2026-03-18` +- `--write-raw` + +Example with custom paths: + +```bash +python3 scripts/tk_impact_analysis.py \ + --platform Web \ + --source-repository /path/to/web-repo \ + --regress-path /path/to/web/regress.txt \ + --write-raw +``` + +The local runner will: + +- resolve default platform paths +- auto-detect the nearest parent git repository as the source repository unless overridden +- fetch release branches from `origin` on every run +- validate the git worktree and branches +- collect diff and commit metadata +- parse `regress.txt` +- generate a heuristic Markdown report +- generate a focused TXT test collection +- optionally generate `raw-git-data.txt` for a later AI refinement pass + +If fetch fails because SSH authentication is required: + +1. Run this manually in your own terminal and complete the SSH prompt: + `python3 .codex/skills/tk-impact-analisys/scripts/tk_impact_analysis.py --platform Android --write-raw` +2. After telling Codex it is done, Codex should verify that the generated files in `reports///` were updated recently before using them. + +The generated TXT test collection includes: + +- selected existing regress rows +- suggested tests for unmatched files +- Missing Blocks +- Additional Checks + +Module-aware matching rules: + +- prefer concrete module names from the diff, for example `BatteryRecharge` and `BatteryRefill` +- search `regress.txt` using those names and obvious localized variants, for example `Battery`, `батарейка`, `Recharge`, `Refill` +- if those searches still find no matching regression rows, mark the functionality as new or uncovered and add focused tests + +## Recommended Cost-Optimized Flow + +1. Run the local script first with `--write-raw`. +2. Let Codex refine the generated `raw-git-data.txt`, `report.md`, and `test-collection.txt` instead of redoing repo discovery. + +This keeps the cheap deterministic work local and uses AI mainly for QA judgment and report refinement. + +The recommendations are still heuristic, so they should be reviewed by QA before execution. + +## Current Workspace Note + +At the moment, this repository is the default source repository for `Android`, and the bundled default regression data present in this workspace is for `Android`. diff --git a/.codex/skills/tk-impact-analisys/SKILL.md b/.codex/skills/tk-impact-analisys/SKILL.md new file mode 100644 index 000000000..de0af56a0 --- /dev/null +++ b/.codex/skills/tk-impact-analisys/SKILL.md @@ -0,0 +1,207 @@ +--- +name: tk-impact-analisys +description: Analyze QA regression impact for Tonkeeper Android, iOS, or Web by comparing the current branch with the relevant release branch, reviewing the platform regress.txt, and recommending test blocks, missing coverage, extra checks, and a generated TXT test collection. +--- + +# TK Impact Analisys + +Use this skill when the user wants release impact analysis for Tonkeeper and needs QA recommendations driven by code changes plus the platform regression checklist. + +## Startup Parameters + +Require these parameters in the user request or infer them from nearby context: + +- `PLATFORM`: one of `Android`, `iOS`, `Web` + +Optional parameters: + +- `BASE_BRANCH`: overrides the current checked out branch +- `RELEASE_BRANCH`: overrides the auto-selected release branch +- `SOURCE_REPOSITORY`: overrides the source repository used for git comparison +- `REPO_PATH`: legacy alias for `SOURCE_REPOSITORY` +- `REGRESS_PATH`: overrides the default `regress.txt` path for the chosen platform +- `REPORT_PATH`: overrides the default Markdown report path +- `TESTS_COLLECTION_PATH`: overrides the default generated TXT collection path + +For default folder mapping and current workspace availability, read [references/required-data.md](references/required-data.md). + +For the reusable invocation prompt, read [references/prompt-template.md](references/prompt-template.md). + +## Workflow + +1. Prefer existing generated artifacts when they are available. +- Before redoing git discovery, check whether the run already has: + - `reports///raw-git-data.txt` + - `reports///report.md` + - `reports///test-collection.txt` +- If those files exist and look current for the requested run, use them as the primary input for the AI refinement pass. +- Only redo repository inspection if the generated files are missing, clearly stale, or the user asked for a fresh rerun. + +2. Resolve the platform-specific paths. +- Use `SOURCE_REPOSITORY` or `REPO_PATH` and `REGRESS_PATH` if the user provided them. +- Otherwise auto-detect the source repository from the nearest parent git repository that contains the skill, and use the default `regress.txt` mapping from [references/required-data.md](references/required-data.md). +- This keeps the skill portable: you can copy the skill into another platform repository and it will compare against that repository by default. +- If the selected source repository or `regress.txt` is missing, stop the analysis and report exactly what is missing. + +3. Validate the git inputs inside the selected repository. +- Confirm the repo exists and is a git worktree. +- In the local-script flow, fetch release refs from `origin` first so the latest release set is available locally. +- If fetch fails with SSH authentication errors such as passphrase prompts or `Permission denied (publickey)`, stop and ask the user to run `python3 .codex/skills/tk-impact-analisys/scripts/tk_impact_analysis.py --platform --write-raw` manually in their terminal. +- After the user says it is done, do not trust that confirmation by itself. Verify that `reports///raw-git-data.txt`, `report.md`, and `test-collection.txt` were updated recently before using them. +- Detect the current branch unless `BASE_BRANCH` was provided explicitly. +- Discover release branches matching: + - `release/YY.MM.Iterator` + - `release/YY.MM.Iterator-hotfixiterator` +- If the current branch is the latest release branch, compare it with the previous release branch. +- If the current branch is a release branch but not the latest release, compare it with the latest release branch. +- If the current branch is not a release branch, compare it with the latest release branch. +- If `RELEASE_BRANCH` was provided explicitly, use it as the comparison target. +- Confirm both comparison refs exist locally or as remote refs. +- Prefer comparing `CURRENT_BRANCH...RELEASE_BRANCH` so the diff is relative to the merge base. + +4. Inspect branch delta with git. +- Start with: + - `git diff --name-status CURRENT_BRANCH...RELEASE_BRANCH` + - `git diff --stat CURRENT_BRANCH...RELEASE_BRANCH` + - `git log --left-right --cherry-pick --oneline CURRENT_BRANCH...RELEASE_BRANCH` +- Then inspect important changed files directly. +- Cluster changes into QA-relevant areas such as onboarding, wallets, send/receive, swaps, signing, security, settings, localization, analytics, widgets, build config, feature flags, and shared infrastructure. +- Extract concrete module or feature names from changed paths and use them in the report. +- Search `regress.txt` by those module names first, including obvious localized variants when they exist. +- Example: `BatteryRecharge` and `BatteryRefill` should trigger searches such as `Battery`, `батарейка`, `Recharge`, and `Refill`. +- Highlight files that did not match any known QA area. +- For each unmatched file, explain the affected module or feature directly instead of using generic filename-role guesses. +- Suggest one or more concrete tests for each unmatched file and include them in both the Markdown report and the generated TXT test collection. +- Ignore the `AGENTS.md` file since it is not user-facing code and does not have direct QA implications. + +5. Review the platform regression file as an expert QA. +- Treat `regress.txt` as the current regression source of truth for that platform. +- Identify the existing regression blocks already covering the changed areas. +- Call out weak coverage, stale wording, duplicated checks, overly broad blocks, or missing negative cases. +- Do not just keyword-match titles; reason from the changed code paths and user flows. + +6. Build the impact recommendation. +- Recommend regression blocks from `regress.txt` that should be executed. +- Add a dedicated section for changed files that did not match a known QA area. +- Show which module names and search terms were used for each unmatched file. +- If the module-aware search still finds no matching regression cases, call out that the functionality is new or uncovered and should be tested explicitly. +- Suggest new blocks that should be added to `regress.txt` because code changes are not covered well enough. +- Avoid grouping existing regression cases when the recommended set is small and specific. +- If there are only a few relevant existing tests, list them as separate rows in the report instead of collapsing them into a broad range. +- Use grouped ranges only when the recommendation truly covers a large contiguous block and individual rows would hurt readability. +- Suggest extra checks outside the listed blocks when the diff implies risk: + - migrations + - permissions + - offline mode + - upgrade path + - localization + - analytics + - feature flags + - crash-prone edge cases + - platform-specific UI states + +7. Persist the result to Markdown. +- Use `report.md` in the skill directory as the template for the report structure. +- Duplicate the final user-facing analysis into `REPORT_PATH` or the default run report path `reports///report.md`. +- Create the dated platform folder if it does not exist. +- If the run report already exists, update the existing sections in place instead of regenerating the whole document. +- Preserve stable headings and only replace the content under the analysis sections that changed. +- Keep the Markdown easy to scan: short sections, flat bullets, blank lines between sections, and no giant unbroken paragraphs. + +8. Generate a TXT test collection with the recommended tests. +- Use `test-collection-template.txt` in the skill directory as the TXT template. +- Write the actual collection into `TESTS_COLLECTION_PATH` or the default run path `reports///test-collection.txt`. +- The generated file must follow the same indented plain-text style as the platform `regress.txt`. +- Keep the generated collection focused on the recommended execution scope for this run, not the entire master regression suite. +- Group tests into logical blocks with indentation. +- It is acceptable to rewrite the run TXT collection fully on each execution; treat it as a generated artifact. +- When useful, append short source hints inline such as `[regress: 24]` or `[reason: high risk]`. + +## Output Contract + +Return these sections in this order: + +1. `Scope` +- platform +- repo path +- regress path +- current branch +- compared release branch +- generated test collection path +- assumptions and missing inputs + +2. `Changed Areas` +- changed modules and user-facing flows +- branch-level summary with risk notes + +3. `Changed Files Without Known QA Area` +- list unmatched files or the most important unmatched subset +- explain probable impact +- suggest focused tests + +4. `Run These Existing Regression Blocks` +- list concrete blocks or cases from `regress.txt` +- when there are only a few relevant existing cases, list each case on its own row +- explain why each block matters for this diff + +5. `Add These Missing Blocks` +- propose new regression blocks or case groups missing from `regress.txt` +- explain what code change created the need + +6. `Additional Checks` +- targeted exploratory checks, integrations, edge cases, and non-functional risks + +7. `Open Questions` +- anything blocking confidence, such as missing platform data, unclear feature flags, or suspicious files without matching tests + +Also mirror the same content into the Markdown report file with the same section order. +Also generate a TXT test collection with the recommended tests for this run. +Also include the unmatched-file test suggestions, Missing Blocks, and Additional Checks in the generated TXT collection. + +## Report File Rules + +- Template path: `tk-impact-analisys/report.md` +- Default run report path: `tk-impact-analisys/reports///report.md` +- Default raw git bundle path: `tk-impact-analisys/reports///raw-git-data.txt` +- Use the root `report.md` as the template only. +- Write actual analysis results into the dated run report path. +- Do not delete unrelated notes that are outside the standard analysis sections. +- If the run report already has the standard headings, replace only the section bodies. +- If the run report is missing, create it from the root `report.md` template and then fill the sections. +- Make the file more readable without full regeneration: + - keep headings as `#` and `##` + - use short summary bullets instead of dense prose + - keep one idea per bullet + - keep file paths, branches, dates, and case ids in backticks + - do not collapse a short list of existing regression cases into one grouped range + +## TXT Test Collection Rules + +- Template path: `tk-impact-analisys/test-collection-template.txt` +- Default run test collection path: `tk-impact-analisys/reports///test-collection.txt` +- Use the root `test-collection-template.txt` as the template only. +- Write actual generated test collections into the dated run TXT path. +- Keep the structure compatible with the platform `regress.txt`: + - one test or block per line + - indentation defines nesting + - no CSV headers or metadata rows +- Include only the recommended test subset for the specific analysis. +- Include: + - existing regress rows selected for this run + - suggested tests for unmatched files + - Missing Blocks + - Additional Checks +- Prefer this line style: + - block line: `Wallet import and legacy mnemonic` + - child line: ` Import TON wallet by 24-word phrase [regress: 24]` +- Keep rationale short and inline. +- When only a few existing regress tests are selected, prefer one line per concrete test instead of one line with a wide grouped range. +- Do not keep grouped existing regress references in the generated TXT collection when the selected set is small enough to list individually. + +## Quality Bar + +- Be opinionated and risk-based, not exhaustive for its own sake. +- Prefer concrete test intent over generic QA advice. +- Prefer module names and user flows over generic type-based guesses like `ViewModel` or `Service`. +- Tie every recommendation back to changed files, changed behavior, or a clear coverage gap. +- If the chosen platform has no repo or no `regress.txt`, say so plainly instead of inventing analysis. diff --git a/.codex/skills/tk-impact-analisys/agents/openai.yaml b/.codex/skills/tk-impact-analisys/agents/openai.yaml new file mode 100644 index 000000000..c01cc998a --- /dev/null +++ b/.codex/skills/tk-impact-analisys/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "TK Impact Analisys" + short_description: "QA impact analysis for release branch diffs" + default_prompt: "Use $tk-impact-analisys to analyze Android, iOS, or Web release impact by comparing develop with a release branch and mapping the diff to regress.csv coverage." diff --git a/.codex/skills/tk-impact-analisys/references/prompt-template.md b/.codex/skills/tk-impact-analisys/references/prompt-template.md new file mode 100644 index 000000000..2e71ef76c --- /dev/null +++ b/.codex/skills/tk-impact-analisys/references/prompt-template.md @@ -0,0 +1,37 @@ +# Prompt Template + +Use this when invoking the skill. + +```text +Use $tk-impact-analisys to perform QA impact analysis for PLATFORM=. +Detect the current branch and compare it with the correct release branch automatically. +If the local run already produced `raw-git-data.txt`, `report.md`, and `test-collection.txt` for this execution date, use those files as the primary input and refine them instead of redoing git discovery. +Use the current source repository for git comparison and the platform-specific `regress.txt` unless I provide SOURCE_REPOSITORY or REGRESS_PATH overrides. +Use `report.md` as the template and mirror the final analysis into REPORT_PATH=//report.md>, updating that run report in place instead of regenerating the whole report. +Use `test-collection-template.txt` as the TXT template and generate TESTS_COLLECTION_PATH=//test-collection.txt> with the recommended test subset. + +Return: +1. Scope and assumptions. +2. Changed areas and risk summary. +3. Changed files that did not match a known QA area, with probable impact and suggested tests. +4. Existing regress.txt blocks that should be executed, listed as separate rows when there are only a few relevant tests. +5. Missing blocks that should be added to regress.txt. +6. Additional exploratory and edge-case checks based on the diff. +7. Open questions or blockers. + +Also ensure the generated TXT test collection includes: +- selected existing regress rows +- unmatched-file test suggestions +- Missing Blocks +- Additional Checks +``` + +## Examples + +```text +Use $tk-impact-analisys to analyze iOS impact. Detect the current branch, compare it with the correct release branch automatically, and use the default repo and regress.txt paths. +``` + +```text +Use $tk-impact-analisys to analyze Web impact. Detect the current branch, compare it with the correct release branch automatically. SOURCE_REPOSITORY=/path/to/web-repo REGRESS_PATH=/path/to/web/regress.txt +``` diff --git a/.codex/skills/tk-impact-analisys/references/required-data.md b/.codex/skills/tk-impact-analisys/references/required-data.md new file mode 100644 index 000000000..89c848898 --- /dev/null +++ b/.codex/skills/tk-impact-analisys/references/required-data.md @@ -0,0 +1,92 @@ +# Required Data + +## Startup Parameters + +- `PLATFORM`: `Android`, `iOS`, or `Web` + +Optional: + +- `SOURCE_REPOSITORY`: override source repository location +- `BASE_BRANCH`: overrides the current checked out branch +- `RELEASE_BRANCH`: overrides the auto-selected release branch +- `REPO_PATH`: legacy alias for `SOURCE_REPOSITORY` +- `REGRESS_PATH`: override regression checklist TXT location +- `REPORT_PATH`: override Markdown report output +- `TESTS_COLLECTION_PATH`: override generated TXT collection output + +Default report locations: + +- template: `report.md` +- actual run report: `reports///report.md` +- tests template: `test-collection-template.txt` +- actual run tests collection: `reports///test-collection.txt` +- raw git bundle: `reports///raw-git-data.txt` + +## Default Platform Mapping + +- source repository: + - auto-detect the nearest parent git repository containing the skill +- `iOS` + - regress file: `sources/iOS/regress_EN.txt` +- `Android` + - regress file: `sources/Android/regress.txt` +- `Web` + - regress file: `sources/Web/regress.txt` + +Resolve the default `regress.txt` paths relative to the skill directory. + +## Current Workspace State + +- Present now: + - source repository auto-detects to the repo containing this skill + - `sources/iOS/regress.txt` + - `sources/iOS/regress_EN.txt` +- Missing now: + - `sources/Android/regress.txt` + - `sources/Web/regress.txt` + +If the user selects `Android` or `Web` and does not provide override paths, the skill can still use the current repository for git comparison, but it must stop and report a missing default `regress.txt` file when the platform data is absent. + +## Minimum Data Needed For A Useful Analysis + +- A valid git repository for the chosen platform +- The current branch or an explicit `BASE_BRANCH` +- At least one release branch matching `release/YY.MM.Iterator` or `release/YY.MM.Iterator-hotfixiterator` +- A platform regression checklist in `regress.txt` + +## Recommended Commands + +Run these inside the selected repo: + +```bash +git fetch origin '+refs/heads/release/*:refs/remotes/origin/release/*' +git rev-parse --is-inside-work-tree +git branch --show-current +git branch --list 'release/*' +git branch -r --list 'origin/release/*' +git diff --name-status CURRENT_BRANCH...SELECTED_RELEASE_BRANCH +git diff --stat CURRENT_BRANCH...SELECTED_RELEASE_BRANCH +git log --left-right --cherry-pick --oneline CURRENT_BRANCH...SELECTED_RELEASE_BRANCH +``` + +Branch selection rules: + +- if `CURRENT_BRANCH` is the latest release, compare it with the previous release +- if `CURRENT_BRANCH` is a release but not the latest release, compare it with the latest release +- if `CURRENT_BRANCH` is not a release branch, compare it with the latest release + +Then inspect high-risk files from the diff and cross-check them against relevant blocks in `regress.txt`. +Also inspect files that did not match any known QA area, explain probable impact, and suggest focused tests for them. + +For the lowest-cost AI flow, generate these local artifacts first and then hand them to Codex: + +- `raw-git-data.txt` +- `report.md` +- `test-collection.txt` + +The generated `test-collection.txt` should include: + +- selected existing regress rows +- unmatched-file suggested tests +- Missing Blocks +- Additional Checks diff --git a/.codex/skills/tk-impact-analisys/report.md b/.codex/skills/tk-impact-analisys/report.md new file mode 100644 index 000000000..2d60fb564 --- /dev/null +++ b/.codex/skills/tk-impact-analisys/report.md @@ -0,0 +1,37 @@ +# TK Impact Analysis Report + +## Scope + +- Platform: `TBD` +- Repo path: `TBD` +- Regress path: `TBD` +- Base branch: `TBD` +- Release branch: `TBD` +- Generated test collection path: `TBD` +- Execution date: `TBD` +- Assumptions: +- `TBD` + +## Changed Areas + +- `TBD` + +## Changed Files Without Known QA Area + +- `TBD` + +## Run These Existing Regression Blocks + +- `TBD` + +## Add These Missing Blocks + +- `TBD` + +## Additional Checks + +- `TBD` + +## Open Questions + +- `TBD` diff --git a/.codex/skills/tk-impact-analisys/scripts/tk_impact_analysis.py b/.codex/skills/tk-impact-analisys/scripts/tk_impact_analysis.py new file mode 100644 index 000000000..945075e50 --- /dev/null +++ b/.codex/skills/tk-impact-analisys/scripts/tk_impact_analysis.py @@ -0,0 +1,1120 @@ +#!/usr/bin/env python3 +"""Local runner for the tk-impact-analisys skill.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + + +SKILL_DIR = Path(__file__).resolve().parents[1] +REPORT_TEMPLATE = SKILL_DIR / "report.md" +TEST_COLLECTION_TEMPLATE = SKILL_DIR / "test-collection-template.txt" +RELEASE_BRANCH_RE = re.compile(r"^release/(?P\d{2})\.(?P\d{2})\.(?P\d+)(?:-(?P\d+))?$") + +DEFAULTS = { + "iOS": { + "regress": SKILL_DIR / "sources" / "iOS" / "regress_EN.txt", + }, + "Android": { + "regress": SKILL_DIR / "sources" / "Android" / "regress.txt", + }, + "Web": { + "regress": SKILL_DIR / "sources" / "Web" / "regress.txt", + }, +} + +SECTION_ORDER = [ + "Scope", + "Changed Areas", + "Changed Files Without Known QA Area", + "Run These Existing Regression Blocks", + "Add These Missing Blocks", + "Additional Checks", + "Open Questions", +] + +PATH_CATEGORY_RULES = [ + ("import", "Wallet import and recovery phrase"), + ("mnemonic", "Wallet import and recovery phrase"), + ("backup", "Backup and recovery"), + ("restore", "Backup and recovery"), + ("migrat", "Migration and upgrade flows"), + ("wallet", "Wallet screen and account state"), + ("browser", "Browser and connected dApps"), + ("tonconnect", "Browser and connected dApps"), + ("proof", "Signing and proof flows"), + ("sign", "Signing and proof flows"), + ("send", "Send flow and confirmations"), + ("receive", "Receive flow and address sharing"), + ("comment", "Send flow and confirmations"), + ("swap", "Swap flow"), + ("stake", "Stake flow"), + ("tron", "TRON and USDT TRC20"), + ("trc20", "TRON and USDT TRC20"), + ("usdt", "TRON and USDT TRC20"), + ("widget", "Widgets and extensions"), + ("intent", "Widgets and extensions"), + ("setting", "Settings and toggles"), + ("localiz", "Localization"), + (".lproj", "Localization"), + ("strings", "Localization"), + ("analytics", "Analytics"), + ("flag", "Feature flags and config"), + ("config", "Feature flags and config"), + ("plist", "Build config and app wiring"), + ("xcodeproj", "Build config and app wiring"), + ("xcconfig", "Build config and app wiring"), +] + +CATEGORY_KEYWORDS = { + "Wallet import and recovery phrase": { + "import", + "mnemonic", + "phrase", + "wallet", + "secret", + "seed", + "recovery", + "backup", + }, + "Backup and recovery": {"backup", "recovery", "seed", "phrase", "wallet"}, + "Migration and upgrade flows": {"migration", "upgrade", "restore", "wallet"}, + "Wallet screen and account state": {"wallet", "address", "balance", "history", "token"}, + "Browser and connected dApps": { + "browser", + "connected", + "dapp", + "tonconnect", + "service", + }, + "Signing and proof flows": { + "sign", + "signature", + "proof", + "confirm", + "transaction", + "browser", + }, + "Send flow and confirmations": { + "send", + "confirm", + "history", + "transaction", + "comment", + "address", + }, + "Receive flow and address sharing": { + "receive", + "address", + "qr", + "copy", + "share", + "tron", + "trc20", + }, + "Swap flow": {"swap", "send", "receive", "token"}, + "Stake flow": {"stake", "staking", "pool", "balance"}, + "TRON and USDT TRC20": { + "tron", + "trc20", + "usdt", + "receive", + "export", + "wallet", + "address", + }, + "Widgets and extensions": {"widget", "intent", "extension"}, + "Settings and toggles": {"settings", "toggle", "notification", "biometric", "trc20"}, + "Localization": {"language", "localization", "copy", "translation", "locale"}, + "Analytics": {"analytics", "metric", "event"}, + "Feature flags and config": {"flag", "config", "feature", "toggle"}, + "Build config and app wiring": {"build", "config", "scheme", "entitlement", "plist"}, +} + +MISSING_BLOCK_SUGGESTIONS = { + "Wallet import and recovery phrase": [ + "Add a `legacy mnemonic compatibility` block", + "Verify a previously importable phrase still completes import, unlock, and send paths", + ], + "TRON and USDT TRC20": [ + "Add a `positive TRON balance import` block", + "Verify TRC20 settings and receive/export visibility only appear when the wallet qualifies", + ], + "Browser and connected dApps": [ + "Add a `browser empty-state and connected services` block", + "Verify both empty and non-empty backend responses render correctly", + ], + "Localization": [ + "Add a `changed locale smoke` block", + "Check one long-text locale on the changed screens for truncation and stale copy", + ], + "Build config and app wiring": [ + "Add a `build and launch smoke` block", + "Verify the release branch still launches with the intended app wiring and entitlements", + ], +} + +ADDITIONAL_CHECK_SUGGESTIONS = { + "Wallet import and recovery phrase": [ + "Do one cold-start and unlock pass after importing a wallet", + "Run one imported-wallet smoke on both mainnet and testnet if the app supports both", + ], + "TRON and USDT TRC20": [ + "Do one negative pass where TRC20 must stay hidden for a wallet without qualifying balance", + "Verify balance-loading failures do not cause flicker or false-positive TRON visibility", + ], + "Browser and connected dApps": [ + "Exercise one connect plus one signing action from the in-app browser", + "If available, include a proof-based auth flow rather than connection-only smoke", + ], + "Localization": [ + "Smoke one non-English locale on the touched screens and verify line wrapping", + ], + "Build config and app wiring": [ + "Launch the app from a clean install and confirm the changed configuration path is used", + ], +} + +GENERIC_SEGMENTS = { + "sources", + "source", + "modules", + "module", + "model", + "models", + "view", + "views", + "viewmodel", + "viewmodels", + "controller", + "controllers", + "logic", + "service", + "services", + "assembly", + "assemblies", + "provider", + "providers", + "repository", + "repositories", + "config", + "configuration", + "feature", + "features", + "app", + "localpackages", + "flow", + "flows", + "root", + "backend", + "package", + "swiftpm", +} + +MODULE_TERM_SYNONYMS = { + "battery": {"battery", "батарейка"}, + "recharge": {"recharge"}, + "refill": {"refill"}, + "wallet": {"wallet", "кошелек", "кошелёк"}, + "send": {"send", "transfer", "перевод"}, + "receive": {"receive", "receipt", "получение"}, + "swap": {"swap", "обмен"}, + "stake": {"stake", "staking", "стейкинг"}, + "browser": {"browser", "браузер"}, +} + +TRAILING_TYPE_SUFFIXES = ( + "Model", + "ViewModel", + "Controller", + "Coordinator", + "Service", + "Repository", + "Assembly", + "Provider", + "Mapper", + "Factory", + "Configurator", + "Validator", +) + + +@dataclass +class DiffFile: + status: str + path: str + + +@dataclass +class RegressEntry: + line_no: int + text: str + level: int + chain: list[str] + has_children: bool = False + + @property + def display_text(self) -> str: + return self.chain[-1] if self.chain else self.text + + @property + def block_name(self) -> str: + if len(self.chain) >= 2: + return self.chain[-2] + return self.display_text + + +@dataclass(frozen=True, order=True) +class ReleaseVersion: + yy: int + mm: int + iterator: int + hotfix: int + + +@dataclass(frozen=True) +class ComparisonPlan: + current_branch: str + compare_branch: str + current_ref: str + compare_ref: str + latest_release: str + comparison_reason: str + + +@dataclass(frozen=True) +class UnmatchedFileInsight: + path: str + modules: list[str] + search_terms: list[str] + coverage_note: str + suggested_test: str + + +@dataclass(frozen=True) +class PathSignal: + path: str + modules: list[str] + search_terms: list[str] + focus: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run TK impact analysis locally.") + parser.add_argument("--platform", required=True, choices=sorted(DEFAULTS)) + parser.add_argument("--release-branch", help="Override the auto-selected release branch to compare against.") + parser.add_argument("--base-branch", help="Override the source branch. Defaults to the current checked out branch.") + parser.add_argument( + "--repo-path", + "--source-repository", + dest="repo_path", + help="Override the source repository used for git comparison.", + ) + parser.add_argument("--regress-path") + parser.add_argument("--report-path") + parser.add_argument("--tests-collection-path") + parser.add_argument("--date", default=str(date.today())) + parser.add_argument("--write-raw", action="store_true", help="Write raw git data bundle next to the report.") + return parser.parse_args() + + +def resolve_path(path_str: str | None, default: Path) -> Path: + if not path_str: + return default + path = Path(path_str) + if not path.is_absolute(): + path = (Path.cwd() / path).resolve() + return path + + +def discover_source_repository(start: Path) -> Path | None: + for candidate in [start, *start.parents]: + if (candidate / ".git").exists(): + return candidate + return None + + +def run_git(repo: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or f"git {' '.join(args)} failed") + return proc.stdout.rstrip() + + +def ensure_git_inputs(repo: Path, current_ref: str, compare_ref: str) -> tuple[str, str]: + inside = run_git(repo, "rev-parse", "--is-inside-work-tree") + if inside.strip() != "true": + raise RuntimeError(f"`{repo}` is not a git worktree") + current_sha = verify_ref(repo, current_ref) + compare_sha = verify_ref(repo, compare_ref) + return current_sha, compare_sha + + +def verify_ref(repo: Path, ref: str) -> str: + resolved, sha = resolve_ref(repo, ref) + return sha + + +def resolve_ref(repo: Path, ref: str) -> tuple[str, str]: + candidates = [ref, f"refs/remotes/origin/{ref}"] + for candidate in candidates: + proc = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--verify", candidate], + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return candidate, proc.stdout.strip() + raise RuntimeError(f"Could not resolve branch or ref `{ref}` locally or under `origin/`") + + +def current_branch(repo: Path) -> str: + branch = run_git(repo, "branch", "--show-current").strip() + if branch: + return branch + raise RuntimeError("Could not detect the current branch. Check out a branch or pass `--base-branch` explicitly.") + + +def is_ssh_auth_error(stderr: str) -> bool: + lowered = stderr.lower() + needles = [ + "permission denied (publickey)", + "sign_and_send_pubkey", + "agent refused operation", + "enter passphrase for key", + ] + return any(needle in lowered for needle in needles) + + +def manual_rerun_message(platform: str, execution_date: str) -> str: + reports_dir = SKILL_DIR / "reports" / execution_date / platform + command = f"python3 .codex/skills/tk-impact-analisys/scripts/tk_impact_analysis.py --platform {platform} --write-raw" + return ( + "Failed to fetch release branches from `origin` because SSH authentication is required.\n" + f"Run this command manually in your terminal and complete the SSH prompt:\n{command}\n" + "After it finishes, tell Codex.\n" + "Codex must verify that these files were updated recently before trusting the results:\n" + f"- {reports_dir / 'raw-git-data.txt'}\n" + f"- {reports_dir / 'report.md'}\n" + f"- {reports_dir / 'test-collection.txt'}" + ) + + +def fetch_release_branches(repo: Path, platform: str, execution_date: str) -> str: + proc = subprocess.run( + [ + "git", + "-C", + str(repo), + "fetch", + "origin", + "+refs/heads/release/*:refs/remotes/origin/release/*", + ], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + stderr = proc.stderr.strip() + if is_ssh_auth_error(stderr): + raise RuntimeError(manual_rerun_message(platform, execution_date)) + if "No such remote" in stderr or "couldn't find remote ref" in stderr: + raise RuntimeError( + "Failed to fetch release branches from `origin`. " + "Check that the repo has an `origin` remote and valid access." + ) + raise RuntimeError(stderr or "Failed to fetch release branches from `origin`.") + output = proc.stderr.strip() or proc.stdout.strip() + return output or "Fetched remote release branches from `origin`" + + +def parse_release_branch(branch: str) -> ReleaseVersion | None: + match = RELEASE_BRANCH_RE.fullmatch(branch) + if not match: + return None + return ReleaseVersion( + yy=int(match.group("yy")), + mm=int(match.group("mm")), + iterator=int(match.group("it")), + hotfix=int(match.group("hotfix") or 0), + ) + + +def list_release_branches(repo: Path) -> list[str]: + refs = set() + for raw in [run_git(repo, "branch", "--format", "%(refname:short)"), run_git(repo, "branch", "-r", "--format", "%(refname:short)")]: + for line in raw.splitlines(): + branch = line.strip() + if not branch or "->" in branch: + continue + if branch.startswith("origin/"): + branch = branch[len("origin/") :] + if parse_release_branch(branch): + refs.add(branch) + return sorted(refs, key=lambda item: parse_release_branch(item)) + + +def plan_comparison(repo: Path, explicit_current: str | None, explicit_compare: str | None) -> ComparisonPlan: + source_branch = explicit_current or current_branch(repo) + source_ref, _source_sha = resolve_ref(repo, source_branch) + releases = list_release_branches(repo) + if not releases: + raise RuntimeError("No release branches matching `release/YY.MM.Iterator` or `release/YY.MM.Iterator-hotfix` were found.") + + latest_release = releases[-1] + if explicit_compare: + compare_ref, _compare_sha = resolve_ref(repo, explicit_compare) + return ComparisonPlan( + current_branch=source_branch, + compare_branch=explicit_compare, + current_ref=source_ref, + compare_ref=compare_ref, + latest_release=latest_release, + comparison_reason="Comparison branch was provided explicitly", + ) + + parsed_current = parse_release_branch(source_branch) + if parsed_current and source_branch == latest_release: + if len(releases) < 2: + raise RuntimeError("The current branch is the only known release branch, so there is no previous release to compare against.") + compare_branch = releases[-2] + reason = "Current branch is the latest release, so it is compared with the previous release" + else: + compare_branch = latest_release + if parsed_current: + reason = "Current branch is not the latest release, so it is compared with the latest release" + else: + reason = "Current branch is not a release branch, so it is compared with the latest release" + + return ComparisonPlan( + current_branch=source_branch, + compare_branch=compare_branch, + current_ref=source_ref, + compare_ref=resolve_ref(repo, compare_branch)[0], + latest_release=latest_release, + comparison_reason=reason, + ) + + +def read_diff_files(repo: Path, current_ref: str, compare_ref: str) -> list[DiffFile]: + raw = run_git(repo, "diff", "--name-status", f"{current_ref}...{compare_ref}") + files: list[DiffFile] = [] + for line in raw.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + status = parts[0] + path = parts[-1] + files.append(DiffFile(status=status, path=path)) + return files + + +def read_regress_entries(regress_path: Path) -> list[RegressEntry]: + stack: list[str] = [] + entries: list[RegressEntry] = [] + with regress_path.open("r", encoding="utf-8") as fh: + for index, raw_line in enumerate(fh, start=1): + line = raw_line.rstrip("\n") + if not line.strip(): + continue + spaces = len(line) - len(line.lstrip(" ")) + level = spaces // 4 + text = line.strip() + while len(stack) > level: + stack.pop() + if len(stack) == level: + if stack: + stack.pop() + while len(stack) < level: + stack.append("") + stack.append(text) + chain = [item for item in stack if item] + entries.append(RegressEntry(line_no=index, text=text, level=level, chain=chain)) + for current, next_entry in zip(entries, entries[1:]): + if next_entry.level > current.level: + current.has_children = True + return entries + + +def normalize_words(text: str) -> set[str]: + lowered = text.lower() + lowered = lowered.replace("trc-20", "trc20") + lowered = lowered.replace("dapps", "dapp") + return { + word + for word in re.findall(r"[a-z0-9]{3,}", lowered) + if word not in {"with", "from", "that", "this", "have", "show", "when", "will", "there"} + } + + +def split_identifier_words(text: str) -> list[str]: + pieces = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\d|$)|[A-Z]?[a-z]+|\d+", text) + return [piece.lower() for piece in pieces if piece] + + +def trim_type_suffix(name: str) -> str: + trimmed = name + changed = True + while changed: + changed = False + for suffix in TRAILING_TYPE_SUFFIXES: + if trimmed.endswith(suffix) and len(trimmed) > len(suffix): + trimmed = trimmed[: -len(suffix)] + changed = True + break + return trimmed + + +def dedupe_keep_order(values: list[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + if value not in seen: + seen.add(value) + result.append(value) + return result + + +def humanize_identifier(name: str) -> str: + words = split_identifier_words(name) + if not words: + return name + return " ".join(word.upper() if len(word) <= 3 else word.capitalize() for word in words) + + +def display_modules(labels: list[str]) -> list[str]: + filtered = [ + label + for label in labels + if split_identifier_words(label) and not set(split_identifier_words(label)).issubset(GENERIC_SEGMENTS) + ] + return filtered or labels[:1] + + +def module_terms_from_labels(labels: list[str]) -> list[str]: + terms: list[str] = [] + for label in labels: + terms.extend(term for term in split_identifier_words(label) if term not in GENERIC_SEGMENTS) + if not terms: + for label in labels: + terms.extend(split_identifier_words(label)) + expanded: list[str] = [] + for term in terms: + expanded.extend(sorted(MODULE_TERM_SYNONYMS.get(term, {term}))) + return dedupe_keep_order(expanded) + + +def build_path_signal(path: str) -> PathSignal: + path_obj = Path(path) + parts = list(path_obj.parts) + labels: list[str] = [] + for index, part in enumerate(parts): + if part == "Modules" and index + 1 < len(parts): + labels.append(parts[index + 1]) + elif part.endswith("Module") and part not in {"AppModule"}: + labels.append(part) + + stem = trim_type_suffix(path_obj.stem) + parent_name = path_obj.parent.name + if parent_name not in {"Model", "Models", "Logic", "Services"} and parent_name not in labels: + parent_words = split_identifier_words(parent_name) + if parent_words and not set(parent_words).issubset(GENERIC_SEGMENTS): + labels.append(parent_name) + if stem and stem not in labels: + stem_words = split_identifier_words(stem) + if stem_words and not set(stem_words).issubset(GENERIC_SEGMENTS): + labels.append(stem) + + labels = dedupe_keep_order([label for label in labels if label]) + if not labels: + labels = [path_obj.stem] + + search_terms = module_terms_from_labels(labels) + focus = humanize_identifier(display_modules(labels)[0]) + return PathSignal(path=path, modules=labels, search_terms=search_terms, focus=focus) + + +def build_path_signals(files: list[DiffFile]) -> dict[str, PathSignal]: + return {diff_file.path: build_path_signal(diff_file.path) for diff_file in files} + + +def categorize_files(files: list[DiffFile]) -> dict[str, list[str]]: + categories: dict[str, list[str]] = {} + for diff_file in files: + lowered = diff_file.path.lower() + matched = False + for needle, category in PATH_CATEGORY_RULES: + if needle in lowered: + categories.setdefault(category, []).append(diff_file.path) + matched = True + if not matched: + categories.setdefault("Shared infrastructure and uncategorized changes", []).append(diff_file.path) + return {category: sorted(set(paths)) for category, paths in categories.items()} + + +def summarize_stats(files: list[DiffFile]) -> list[str]: + counts: dict[str, int] = {} + for diff_file in files: + code = diff_file.status[0] + counts[code] = counts.get(code, 0) + 1 + mapping = {"A": "added", "M": "modified", "D": "deleted", "R": "renamed", "C": "copied"} + parts = [] + for code in ["M", "A", "R", "D", "C"]: + if code in counts: + parts.append(f"{counts[code]} {mapping.get(code, code.lower())}") + return parts + + +def score_regress_entries(search_terms: set[str], regress_entries: list[RegressEntry]) -> list[tuple[int, RegressEntry]]: + scored: list[tuple[int, RegressEntry]] = [] + for entry in regress_entries: + if entry.has_children: + continue + entry_words = normalize_words(" ".join(entry.chain)) + overlap = search_terms & entry_words + if overlap: + score = len(overlap) * 10 + min(len(entry.chain), 4) * 2 + min(len(entry.display_text), 80) // 20 + scored.append((score, entry)) + return sorted(scored, key=lambda item: (-item[0], item[1].line_no)) + + +def category_search_terms(category: str, paths: list[str], path_signals: dict[str, PathSignal]) -> set[str]: + terms = set(CATEGORY_KEYWORDS.get(category, normalize_words(category))) + for path in paths: + terms.update(path_signals[path].search_terms) + return terms + + +def select_regress_entries( + categories: dict[str, list[str]], + regress_entries: list[RegressEntry], + path_signals: dict[str, PathSignal], +) -> list[tuple[str, RegressEntry]]: + recommendations: list[tuple[str, RegressEntry, int]] = [] + seen: set[int] = set() + for category, paths in categories.items(): + scored = score_regress_entries(category_search_terms(category, paths, path_signals), regress_entries) + for score, entry in scored[:8]: + if entry.line_no in seen: + continue + seen.add(entry.line_no) + recommendations.append((category, entry, score)) + recommendations.sort(key=lambda item: (list(categories).index(item[0]), item[1].line_no)) + return [(category, entry) for category, entry, _score in recommendations] + + +def build_scope_section( + platform: str, + repo_path: Path, + regress_path: Path, + current_branch_name: str, + compare_branch_name: str, + current_sha: str, + compare_sha: str, + latest_release: str, + tests_collection_path: Path, + execution_date: str, + assumptions: list[str], +) -> list[str]: + return [ + f"- Platform: `{platform}`", + f"- Repo path: `{repo_path}`", + f"- Regress path: `{regress_path}`", + f"- Current branch: `{current_branch_name}` at `{current_sha}`", + f"- Compared against release branch: `{compare_branch_name}` at `{compare_sha}`", + f"- Latest discovered release branch: `{latest_release}`", + f"- Generated test collection path: `{tests_collection_path}`", + f"- Execution date: `{execution_date}`", + "- Assumptions:", + *[f"- {item}" for item in assumptions], + ] + + +def build_changed_areas( + categories: dict[str, list[str]], + path_signals: dict[str, PathSignal], + diff_files: list[DiffFile], + diff_stat: str, + log_lines: list[str], +) -> list[str]: + bullets: list[str] = [] + stats = ", ".join(summarize_stats(diff_files)) or "0 changed files" + bullets.append(f"- Branch delta includes {len(diff_files)} changed files: {stats}") + if diff_stat.strip(): + first_stat_line = diff_stat.splitlines()[-1] + bullets.append(f"- Git diff summary: `{first_stat_line}`") + if log_lines: + bullets.append(f"- Unique branch commits in the comparison window: `{len(log_lines)}`") + for category, paths in categories.items(): + sample = ", ".join(f"`{Path(path).name}`" for path in paths[:4]) + suffix = "" if len(paths) <= 4 else f", and {len(paths) - 4} more" + bullets.append(f"- {category} changed in {len(paths)} file(s): {sample}{suffix}") + modules = dedupe_keep_order( + [ + module + for path in paths + for module in display_modules(path_signals[path].modules) + ] + ) + if modules: + bullets.append(f"- Highlighted modules/features: {', '.join(f'`{module}`' for module in modules[:6])}") + return bullets + + +def explain_unmatched_file(path: str, regress_entries: list[RegressEntry], path_signals: dict[str, PathSignal]) -> UnmatchedFileInsight: + signal = path_signals[path] + matched_entries = score_regress_entries(set(signal.search_terms), regress_entries) + if matched_entries: + matched_lines = ", ".join(f"`{entry.line_no}`" for _score, entry in matched_entries[:3]) + coverage_note = f"Search terms {', '.join(f'`{term}`' for term in signal.search_terms[:6])} matched regress rows {matched_lines}." + else: + coverage_note = ( + f"No regress rows matched {', '.join(f'`{term}`' for term in signal.search_terms[:6])}. " + f"Treat `{signal.focus}` as new or uncovered functionality and add focused coverage." + ) + suggested_test = ( + f"Exercise the `{signal.focus}` flow end to end: happy path, validation, backend/payment failure, " + "and the post-action state shown to the user." + ) + return UnmatchedFileInsight( + path=path, + modules=signal.modules, + search_terms=signal.search_terms, + coverage_note=coverage_note, + suggested_test=suggested_test, + ) + + +def build_unmatched_files_section( + categories: dict[str, list[str]], + regress_entries: list[RegressEntry], + path_signals: dict[str, PathSignal], +) -> list[str]: + unmatched_paths = categories.get("Shared infrastructure and uncategorized changes", []) + if not unmatched_paths: + return ["- All changed files matched at least one known QA area"] + bullets = [f"- {len(unmatched_paths)} file(s) did not match a known QA area and need explicit review"] + for path in unmatched_paths[:12]: + insight = explain_unmatched_file(path, regress_entries, path_signals) + bullets.append(f"- `{path}`") + bullets.append( + f"- Highlighted module(s): {', '.join(f'`{module}`' for module in display_modules(insight.modules)[:4])}" + ) + bullets.append(f"- Regress search terms: {', '.join(f'`{term}`' for term in insight.search_terms[:6])}") + bullets.append(f"- Coverage note: {insight.coverage_note}") + bullets.append(f"- Suggested test: {insight.suggested_test}") + if len(unmatched_paths) > 12: + bullets.append(f"- {len(unmatched_paths) - 12} more unmatched file(s) remain for manual triage") + return bullets + + +def build_existing_blocks( + recommendations: list[tuple[str, RegressEntry]], + categories: dict[str, list[str]], + path_signals: dict[str, PathSignal], +) -> list[str]: + if not recommendations: + return ["- No confident existing `regress.txt` matches were found from simple keyword heuristics"] + bullets: list[str] = [] + for category, entry in recommendations: + modules = dedupe_keep_order( + [ + module + for path in categories.get(category, []) + for module in display_modules(path_signals[path].modules) + ] + ) + reason_target = ", ".join(f"`{module}`" for module in modules[:3]) if modules else f"`{category}`" + bullets.append(f"- `{entry.line_no}`: {entry.display_text}") + bullets.append(f"- Why: matched the updated area {reason_target}") + return bullets + + +def build_missing_blocks( + categories: dict[str, list[str]], + regress_entries: list[RegressEntry], + path_signals: dict[str, PathSignal], +) -> list[str]: + bullets: list[str] = [] + seen = set() + for category in categories: + for item in MISSING_BLOCK_SUGGESTIONS.get(category, []): + if item not in seen: + seen.add(item) + bullets.append(f"- {item}") + unmatched_paths = categories.get("Shared infrastructure and uncategorized changes", []) + for path in unmatched_paths: + signal = path_signals[path] + if score_regress_entries(set(signal.search_terms), regress_entries): + continue + module_names = ", ".join(f"`{module}`" for module in display_modules(signal.modules)[:3]) + search_terms = ", ".join(f'`{term}`' for term in signal.search_terms[:6]) + block = f"- Add a dedicated regression block for {module_names} using search terms {search_terms}" + if block not in seen: + seen.add(block) + bullets.append(block) + if not bullets: + bullets.append("- No obvious missing regression blocks were inferred from the changed files alone") + return bullets + + +def build_additional_checks(categories: dict[str, list[str]]) -> list[str]: + bullets: list[str] = [] + seen = set() + for category in categories: + for item in ADDITIONAL_CHECK_SUGGESTIONS.get(category, []): + if item not in seen: + seen.add(item) + bullets.append(f"- {item}") + if "Localization" not in categories: + bullets.append("- Smoke one default-locale flow after the release build is installed") + return bullets + + +def build_open_questions(categories: dict[str, list[str]], recommendations: list[tuple[str, RegressEntry]]) -> list[str]: + bullets = [ + "- This local runner uses heuristics for mapping changed files to regress coverage; review the recommended rows before execution", + ] + if not recommendations: + bullets.append("- No strong `regress.txt` matches were found, so the report likely needs manual QA curation") + if "Shared infrastructure and uncategorized changes" in categories: + bullets.append("- Some files did not match a known QA area and may need manual triage") + return bullets + + +def bullet_payload(line: str) -> str: + return line[2:] if line.startswith("- ") else line + + +def replace_or_append_sections(existing: str, sections: dict[str, list[str]]) -> str: + if not existing.strip(): + lines = ["# TK Impact Analysis Report", ""] + for name in SECTION_ORDER: + lines.extend([f"## {name}", "", *sections[name], ""]) + return "\n".join(lines).rstrip() + "\n" + + rendered = existing + for index, name in enumerate(SECTION_ORDER): + next_name = SECTION_ORDER[index + 1] if index + 1 < len(SECTION_ORDER) else None + body = "\n".join(sections[name]).rstrip() + replacement = f"## {name}\n\n{body}\n" + pattern = rf"## {re.escape(name)}\n.*?(?=\n## {re.escape(next_name)}\n|\Z)" if next_name else rf"## {re.escape(name)}\n.*\Z" + if re.search(pattern, rendered, flags=re.S): + rendered = re.sub(pattern, replacement, rendered, count=1, flags=re.S) + else: + rendered = rendered.rstrip() + f"\n\n{replacement}" + return rendered.rstrip() + "\n" + + +def build_test_collection( + platform: str, + current_branch_name: str, + compare_branch_name: str, + recommendations: list[tuple[str, RegressEntry]], + categories: dict[str, list[str]], + regress_entries: list[RegressEntry], + path_signals: dict[str, PathSignal], + missing_blocks: list[str], + additional_checks: list[str], +) -> str: + lines = [f"{platform} {current_branch_name} vs {compare_branch_name} impact suite"] + by_category: dict[str, list[RegressEntry]] = {} + for category, entry in recommendations: + by_category.setdefault(category, []).append(entry) + for category in categories: + existing_entries = by_category.get(category, []) + if not existing_entries and category not in ADDITIONAL_CHECK_SUGGESTIONS: + continue + lines.append(f" {category}") + for entry in existing_entries: + lines.append(f" {entry.display_text} [regress: {entry.line_no}]") + for item in ADDITIONAL_CHECK_SUGGESTIONS.get(category, [])[:1]: + if not existing_entries: + lines.append(f" {item} [reason: added for this release]") + unmatched_paths = categories.get("Shared infrastructure and uncategorized changes", []) + if unmatched_paths: + lines.append(" Changed files without known QA area") + for path in unmatched_paths[:12]: + insight = explain_unmatched_file(path, regress_entries, path_signals) + lines.append(f" Review `{path}` [reason: unmatched file]") + lines.append( + f" Search {', '.join(f'`{term}`' for term in insight.search_terms[:6])} in regress " + "[reason: module-aware lookup]" + ) + lines.append(f" {insight.suggested_test} [reason: uncovered module flow]") + if missing_blocks: + lines.append(" Missing Blocks to add") + for item in missing_blocks: + lines.append(f" {bullet_payload(item)} [reason: missing block]") + if additional_checks: + lines.append(" Additional checks") + for item in additional_checks: + lines.append(f" {bullet_payload(item)} [reason: additional check]") + if len(lines) == 1: + lines.extend( + [ + " Impact block", + " Review recommended tests manually [reason: no confident matches found]", + ] + ) + return "\n".join(lines) + "\n" + + +def write_text(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def render_console_summary( + report_path: Path, + tests_collection_path: Path, + categories: dict[str, list[str]], + recommendations: list[tuple[str, RegressEntry]], +) -> str: + lines = [ + f"Report written to: {report_path}", + f"Test collection written to: {tests_collection_path}", + f"Changed QA areas: {', '.join(categories) if categories else 'none'}", + f"Recommended existing regress rows: {len(recommendations)}", + ] + return "\n".join(lines) + + +def main() -> int: + args = parse_args() + defaults = DEFAULTS[args.platform] + default_repo_path = discover_source_repository(SKILL_DIR) + if default_repo_path is None: + print( + "Missing required path: source repository was not detected automatically. " + "Place the skill inside a git repository or pass `--repo-path/--source-repository`.", + file=sys.stderr, + ) + return 1 + + repo_path = resolve_path(args.repo_path, default_repo_path) + regress_path = resolve_path(args.regress_path, defaults["regress"]) + report_path = resolve_path( + args.report_path, + SKILL_DIR / "reports" / args.date / args.platform / "report.md", + ) + tests_collection_path = resolve_path( + args.tests_collection_path, + SKILL_DIR / "reports" / args.date / args.platform / "test-collection.txt", + ) + + missing = [str(path) for path in [repo_path, regress_path] if not path.exists()] + if missing: + for path in missing: + print(f"Missing required path: {path}", file=sys.stderr) + return 1 + + try: + fetch_summary = fetch_release_branches(repo_path, args.platform, args.date) + comparison = plan_comparison(repo_path, args.base_branch, args.release_branch) + current_sha, compare_sha = ensure_git_inputs(repo_path, comparison.current_ref, comparison.compare_ref) + diff_files = read_diff_files(repo_path, comparison.current_ref, comparison.compare_ref) + diff_stat = run_git(repo_path, "diff", "--stat", f"{comparison.current_ref}...{comparison.compare_ref}") + log_output = run_git( + repo_path, + "log", + "--left-right", + "--cherry-pick", + "--oneline", + f"{comparison.current_ref}...{comparison.compare_ref}", + ) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + return 1 + + regress_entries = read_regress_entries(regress_path) + categories = categorize_files(diff_files) + path_signals = build_path_signals(diff_files) + recommendations = select_regress_entries(categories, regress_entries, path_signals) + + assumptions = [ + f"Comparison uses `{comparison.current_branch}...{comparison.compare_branch}`", + comparison.comparison_reason, + "Coverage mapping is heuristic and should be reviewed before execution", + ] + if fetch_summary: + assumptions.append(f"Release refs were refreshed from `origin` before branch selection") + if not args.repo_path: + assumptions.append("Source repository was auto-detected from the nearest parent git repository") + + sections = { + "Scope": build_scope_section( + platform=args.platform, + repo_path=repo_path, + regress_path=regress_path, + current_branch_name=comparison.current_branch, + compare_branch_name=comparison.compare_branch, + current_sha=current_sha, + compare_sha=compare_sha, + latest_release=comparison.latest_release, + tests_collection_path=tests_collection_path, + execution_date=args.date, + assumptions=assumptions, + ), + "Changed Areas": build_changed_areas( + categories=categories, + path_signals=path_signals, + diff_files=diff_files, + diff_stat=diff_stat, + log_lines=[line for line in log_output.splitlines() if line.strip()], + ), + "Changed Files Without Known QA Area": build_unmatched_files_section(categories, regress_entries, path_signals), + "Run These Existing Regression Blocks": build_existing_blocks(recommendations, categories, path_signals), + "Add These Missing Blocks": build_missing_blocks(categories, regress_entries, path_signals), + "Additional Checks": build_additional_checks(categories), + "Open Questions": build_open_questions(categories, recommendations), + } + + if report_path.exists(): + existing_report = report_path.read_text(encoding="utf-8") + else: + existing_report = REPORT_TEMPLATE.read_text(encoding="utf-8") + report_body = replace_or_append_sections(existing_report, sections) + write_text(report_path, report_body) + + test_collection_body = build_test_collection( + platform=args.platform, + current_branch_name=comparison.current_branch, + compare_branch_name=comparison.compare_branch, + recommendations=recommendations, + categories=categories, + regress_entries=regress_entries, + path_signals=path_signals, + missing_blocks=sections["Add These Missing Blocks"], + additional_checks=sections["Additional Checks"], + ) + write_text(tests_collection_path, test_collection_body) + + if args.write_raw: + raw_bundle = "\n\n".join( + [ + "# git diff --name-status", + "\n".join(f"{item.status}\t{item.path}" for item in diff_files), + "# git diff --stat", + diff_stat, + "# git log --left-right --cherry-pick --oneline", + log_output, + ] + ).rstrip() + "\n" + write_text(report_path.with_name("raw-git-data.txt"), raw_bundle) + + print(render_console_summary(report_path, tests_collection_path, categories, recommendations)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.codex/skills/tk-impact-analisys/sources/Android/regress.txt b/.codex/skills/tk-impact-analisys/sources/Android/regress.txt new file mode 100644 index 000000000..c4192c3c2 --- /dev/null +++ b/.codex/skills/tk-impact-analisys/sources/Android/regress.txt @@ -0,0 +1,770 @@ +Important base things + Приложение работает корректно в Режиме Полета с включенным WI-FI + Отключение интернета + Появляется баннер "Нет связи", пропадает, когда интернет появляется + Отображаются балансы/курсы/история, которые были актуальны на последний момент доступа к интернету + Под общим балансом указано "Updated on {{date, time}}" + Размер сборки не увеличился, либо увеличился совсем немного + Язык локализации по умолчанию зависит от языка на устройстве + Если приложение было установлено из apk с сайта -> работает флоу обновления прямо в кошельке при выходе новой версии apk + +На экране "Create a new wallet or add an existing one" есть ссылка на ToU Tonkeeper +Create/Import wallet + Создание кошелька + Необходимо задать четырехзначный PIN код + Back up + Кнопка Continue начитает флоу бэкапа + На экране "Секретный ключ" все 24 слова с их номерами всегда видны полностью + Кнопка Continue ведет открывает экран проверки трех слов + Запрашиваются три рандомных слова из 24 + Пока все поля не заполнены правильно, кнопка "Продолжить" задизейблена + При переходе между экранами "Секретный ключ" и "Проверка резервной копии" значения ключа неизменны + При возвращениии на экран сид фразы, введенные слова стираются + Находясь на экране "Проверка резервной копии", нельзя свайпом "подсмотреть" сид фразу + Кнопка later скипает Back up + Запрашивается доступ к пуш-уведомлениям + Можно кастомизировать кошелек (название, иконка, цвет) + По дефолту название "Tonkeeper", цвет "тёмно-серый", иконка "кошелёк" + По завершению создания, открывается экран Wallet (с кошельком w5) + Отображается блок Finish setting up + Вместе с первой исходящей транзакцией происходит Инициализация кошелька + + Импорт кошелька TON + Можно ввести секретный ключ из 24 или 12 слов + По дефолту выбрано 24 слова + Если в буфере обмена 12 слов -> предвыбрано 12 слов + Подсказки над клавиатурой при вводе слова + Можно вставить все слова списком + Валидация полей при переходе на следующее поле + Кнопка Продолжить неактивна, если хотя бы у одного из полей есть ошибка валидации/слово вписано некорректно + Есть проверка секретной фразы на чексумму -> Ошибка "Неправильный ключ" + Показываем ошибку для мнемоники для мультиаккаунта "Пожалуйста, используйте Tonkeeper Desktop ..." + Появляется экран "Выберите кошельки" со всеми версиями кошельков, на которых есть средства + w5 всегда + Если на старый версиях нет средств, пропускаем экран "Выберите кошельки" + Необходимо задать четырехзначный PIN для подтверждения платежей + Запрашивается доступ к пуш-уведомлениям + Можно кастомизировать кошелек (название, иконка, цвет) + Отображается блок Finish setting up (без пункта "резервная копия") + Импорт кошелька TRON + Можно ввести секретный ключ из 12 слов (флоу аналогично флоу импорта TON) + + + Watch only + Можно добавить кошелек по адресу/DNS + Можно подписаться на пуш-уведомления + На главном экране доступны только кнопка "Deposit". Внутри только Receive tokens, все онрампы скрыты + Есть бейджик Watch only в списке кошельков, на экране Получить + При копировании адреса цвет тоста оранжевый + Скрыта батарейка, сид фраза + При подключении к тон коннекту, нельзя выбрать watch only из списка кошельков (по идее теперь можно вернуть, проверить?) + Watch only можно закконнектить к даппам без ton proof + Боттомшит с объяснением, что это watch only и предложением ввести сид фразу, вылезает: + При добавлении кошелька + При тапе на задизебленные кнопки на главном экране + При попытке подключиться к даппу с ton proof (не делали, но по идее надо) + При попытке сделать транзакцию, если подключился без ton proof + + Testnet + Можно добавить по сид фразе + На главном экране доступны только кнопки "Withdraw", "Deposit". Внутри только отправка/получение, все онрампы скрыты + Есть бейджик Testnet в списке кошельков + При копировании адреса цвет тоста оранжевый + Транзакции отправляется в сети Testnet + Есть проверка, если ввели адрес Mainnet + + Signer + Можно подключить Signer и Tonkeeper на одном девайсе по диплинку + Можно подключить Signer и Tonkeeper на разных девайсах по QR + Есть бейджик Signer рядом с адресом на главном экране, в списке кошельков + Скрыты Батарейка, Back up, Gasless, генерация TRON адреса + Для онрампов/обмена скрыт депозит/вывод USDT TRC20 (т.к. не прокинуть адрес) + Можно отправить обычную транзакию TON, Jetton, NFT (комиссия в TON) + Можно законнектиться к DApp БЕЗ tonproof и подписать тон коннект транзакцию + Можно сделать нативный свап omniston (кнопка Swap на главном экране) + Можно подтвердить ввод/вывод со стейкинга + Можно продлить DNS, TG Username + + Ledger + Подключается по USB + Подключается по Bluetooth + По дефолту генерирует 10 адресов + Есть бейджик Ledger рядом с адресом на главном экране, в списке кошельков + Скрыты Батарейка, Back up, Gasless, генерация TRON адреса + Для онрампов/обмена скрыт депозит/вывод USDT TRC20 (т.к. не прокинуть адрес) + Можно отправить обычную транзакию TON, Jetton, NFT (комиссия в TON) + Можно законнектиться к DApp с tonproof и подписать тон коннект транзакцию + Можно сделать нативный свап omniston (кнопка Swap на главном экране) + Можно подтвердить ввод/вывод со стейкинга + Можно продлить DNS, TG Username + + Keystone + Подключается по QR + Есть бейджик Keystone рядом с адресом на главном экране, в списке кошельков , на экране Получить + Скрыты Батарейка, Back up, Gasless, генерация TRON адреса + Для онрампов/обмена скрыт депозит/вывод USDT TRC20 (т.к. не прокинуть адрес) + Можно отправить обычную транзакию TON, Jetton, NFT (комиссия в TON) + Можно законнектиться к DApp с tonproof и подписать тон коннект транзакцию + Можно сделать нативный свап omniston (кнопка Swap на главном экране) + Можно подтвердить ввод/вывод со стейкинга + Можно продлить DNS, TG Username + + Tetra + В дев меню можно включить Tetra L2 + На главном экране доступны только кнопки "Withdraw", "Deposit". Внутри только отправка/получение, все онрампы скрыты + В Add wallet появляется опция Импортировать кошелёк Tetra L2 + Кошелек успешно импортируется по 24 словам (TON сид фраза) + Кошелек успешно импортируется по 12 словам (TON сид фраза) + Можно импортировать версию w5 и v4r2, более ранние нет смысла проверять + Адрес пока отображается как UQ + Можно забриджить TON из TON Mainet в Tetra Chain + Можно забриджить TON обратно из Tetra Chain в TON Mainet + Подтягивается курс TON https://tetra.tonapi.io/v2/rates?tokens=ton¤cies=usd + Есть бейджики TETRA на главном экране, в списке Add wallet, на экране Receive + Весь лишний функционал можно скрыть флагами в /keys + Battery + Swap + Onramps + Staking + Сторисы про онбординг + Ethena (USDe) не должна вылезать по дефолту в списке асcетов на главном экране + Collectibles (NFT) скрыть или достаточно оставить заглушку, как когда их нет на балансе + Продвигаемые даппы в браузере + В истории в транзакциях формат адреса UQ/EQ + Ссылка на транзакцию ведет в tetra.tonviewer.com + + + При добавлении последующих кошельков просит ввести пин-код/биометрию + +Backup wallet + Блок Finish setting up + Пользователь создал кошелёк:Уведомления (если не были включены при онбординге), Биометрия, Back up (если не было пройдено при онбординге), Story "How to use your wallet" + Пользователь импортировал кошелёк: Уведомления (если не были включены при онбординге), Биометрия, Story "How to use your wallet", Done + Если пользователь сделал бэкап - появляется кнопка Done + При включении уведомлений/биометрии - тоглы меняют состояние + После просмотра Story "How to use your wallet" - Strory не исчезает + Состояние тоглов на экране Finish setting up совпадает с состояниями таких же тоглов в настройках + + Экран Резервная копия + Пользователь, не сделавший бэкап + Отображается кнопка Back Up Manually + Красная точка на иконке настроек и "Резервная копия", пока пользователь не совершит бэкап + Пользователь, сделавший бэкап + Отображается кнопка Manual Backup Done {{date}} и Show recovery phrase + По тапу любую из кнопкок отображается экран Attention + По тапу на Продолжить запрашивается пароль/биометрия + Back Up Manually -> Показывает сид фразу -> Предлагает ввести три рандомных слова из сид фразы -> Состояние кнопки меняется на Manual Backup Done с текущей датой + Manual Backup Done -> работает аналогично Back Up Manually + Show recovery phrase -> показывает сид фразу и кнопку Copy + Если включен USDT TRC20 -> Есть кнопка Export TRC20 Wallet + Есть предупреждение: что этот ключ только для TRC20 + Можно скопировать ключ + Пользователь с деньгами на балансе + без бэкапа + Красим баланс на главной: от 2 TON -> оранжевый, от 20 TON -> красный + Показываем информационную иконку, которая ведет на экран "Резервная копия" + На экране Резервная копия цветной блок с объяснением: цвет совпадает с цветом суммы + + Подтверждение сохраненной сид фразы + Настройки -> "Выйти из кошелька" + Блок "У меня есть резервная копия" с чекбоксом + "Сделать резервную копию" ведёт на экран Backup + Кнопка "Выйти"/"Удалить" становится активной, когда чекбокс нажат + Не актуально кошельков без сид фразы: сайнер, кейстон, вотч онли - их просто удаляем + +Wallet Screen + Отображается корректная сумма всех средств в фиате (с учетом стейкинга) + Отображается адрес кошелька, можно скопировать + Tokens list + Наверху списка Toncoin, USDT (TON/TRC20), USDe, под ними - Staked, если что-то застейкано + Список токенов отсортирован по балансу и признаку verified + У всех токенов обязательно отображается иконка, название, тикер, количество + При наличии курса: текущий курс, рост/падение, пересчет в фиат + Оранжевая метка для Непровернных токенов + Непроверенные токены не считаются в общий баланс + Token screen + Отображается корректный баланс, {{decimals}} знаков после запятой + Если возможно, отображается текущая цена токена и пересчет текущей суммы в фиат + По тапу на кнопку "..." -> "Подробнее", открывается эксплоер + По тапу на кнопку "..." -> "Виджет", создается виджет с курсом токена на главный экран девайса + По тапу на кнопку "..." -> "Сжечь", отправляет Непроверенные токены на "нулевой" адрес (отображается только для Неверифицированных токенов) + Кнопки Send/Receive/Swap работают как на главном экране, подставляется текущий токен + Отображается список транзакций как на экране History, но с сортировкой по токену + Price chart + Отображается график курса валюты + Пользователь может нажать на график и двигать точку с датой по графику. При этом, стоимость и изменение стоимости будут меняться на выбранный пользователем день, а "price" - меняться на выбранную дату + Дефолтный интервал изменения курса - 1 месяц + Выбранный пользователем интервал изменения курса должен сохраняться даже при выходе из приложения + Есть заглушка при отсутствии интернета [еще не сделано] + Unverified token + Работает для всех токенов с признаком "verification": “none” + На всех экранах (Wallet, History, Manage tokens, детали транзакции) - отображается оранжевая подпись Unverified token + На экране токена отображается оранжевая надпись Unverified token и рядом иконка ( i ) + По тапу на "Unverified token ( i )" на экране жетона транзакции открывается пояснение + + Send TON + Во всех полях работает ctrl+c/ctrl+v + Весь флоу отправки экраны переписываются свайпом вправо, корректно работают кнопки назад, закрыть + Экран Отправить + Адрес + Можно ввести [адрес кошелька TON] ИЛИ [днс] ИЛИ [тг юзернейм] + Работают кнопки "Вставить", "Очистить поле" + Работает валидация, если поле адреса заполнено неверно + Работает валидация на адрес другой сети, в тч из mainnet в testnet и наоборот + Работает валидация на скам адрес + Можно выбрать любой адрес из адресной книги + Сумма + Выбор валюты + По умолчанию отображается валюта TON + Если точкой входа был экран токена, для отправки отображается выбранный токен + Можно менять валюту отправки на экране ввода суммы + Поле "Сумма" + Можно вводить целые и дробные числа в поле ввода суммы отправки. Можно ввести максимум 24 знака в сумме для дробной и целой частей (с учетом разделителя). При этом после запятой максимум столько знаков, сколько decimals токена (TON = 9, USDT = 6) + Ошибка валидации при вводе > current balance + Можно ввести весь баланс, нажав на "Максимум" либо руками + Значение "Доступно:" уменьшается после ввода суммы + Можно указать сумму в фиате, есть кнопка свап для переключения + По тапу на "Продолжить", открывается экран подтверждения транзакции + Кнопка "Продолжить" не активна, пока поле "Адрес или домен" и "Сумма" не заполнены + Комментарий + Можно ввести буквы/цифры/спецсимволы/эмоджи + Отсутствует ограничение на количество символов в комментарии + Если перевод осуществляется на адрес биржи - горит оранжевое предупреждение, невозможно перейти на следующий экран + Экран Подтвердить транзакцию + Отображается имя кошелька, с которого отправляешь + Если перевод контакту из избранного/по днс - отображается поле "Получатель" с названием + Для токенов - сумма отправки указана в токенах, комиссия в тонах + У всех сумм отображается пересчет в фиат + Отображается адрес получателя, сумма перевода и комиссия + Адрес и днс имя получателя можно скопировать + Если было указано "Перевести всё", перед вводом пин-кода появляется предупреждение "Are you sure you want to send all tour balance?". Сумма отправки + Комиссия = сумма всего баланса. + После тапа на "Подтвердить и отправить", открывается экран подтверждения по пин-коду или биометрии + После ввода пин-кода или биометрии открывается экран History (аналогично для других транзакций) + Зашифрованные комментарии + Отправка (тон, токен, нфт) + Под полем комментарий отображается кнопка Encrypt + По тапу на Ecrypt, меняется кнопка Encrypt -> Decrypt, плейсхолдер комментария меняется Comment -> Encrypted comment, рамка вокруг комментария меняется с голубой на зеленую + По тапу на Decrypt, все возвращается в исходное состояние + При отправке на биржу, кнопка Encrypt не отображается (чтобы не зашифровать мемо) + При отправке зашифрованного комментария, на экране Confirm action возле поля комментарий рисуем зеленый замок + История транзакций + Зашифрованные комментарии отображаются как бабл с зеленой иконкой замка + encrypted comment + При первой попытке расшифровки, поднимается боттомшит с описанием того, что такое зашифрованные сообщения. Можно оставить или попросить больше не показывать + По тапу на бабл комментария в истории -> запрашиваем пароль/биометрию -> расшифровываем только этот комментарий + По тапу на ячейку транзакции с зашифрованным комментарием -> открывается экран транзакции -> можно расшифровать комментарий с этого боттомшита + + Статус сервера + Перед отправкой транзакции, приложение запрашивает у тонапи v2/status + Если rest_online=false или indexing_latency>= 271 сек, отправить транзакцию нельзя -> появляется ошибка "Sending error, try again later" / "Ошибка отправки. Попробуйте позже" + + Send USDT TRC20 + Можно ввести/отсканировать/вставить адрес кошелька TRON + Отправлять туда можно только USDT TRC20, флоу аналогичен Send Money TON + USDT TRC20 можно отправить за батарейку, TON и TRX + Попытка отправки TON токенов на адрес TRON + При попытке отправить USDT TON на адрес TRON -> адрес подсвечен красным; предупреждение с предложением поменять c2c TON-TRON на Letsexchange; отправить нельзя + При попытке отправить другие TON токены -> адрес подсвечен красным; красное предупреждение, что адрес сети TRON, отправить нельзя + Попытка отправки USDT TRC20 на адрес TRON + При попытке отправить USDT TRC20 на адрес TON -> адрес подсвечен красным; предупреждение с предложением поменять c2c TRON-TON на Letsexchange; отправить нельзя + + Недостаточно денег на балансе для совершения действия / транзакции + Отображается боттомшит "Недостаточно средств" + В заглушке корректно указано сколько средств необходимо и текущий баланс + Серая кнопка "Купить TON"" открывает боттомшит "Купить TON" со списком доступных бирж + Серая кнопка "Купить {{тикер жетон}}"" открывает раздел DeFi в браузере + + Swap Omniston + Точка входа + Кнопка Swap на главной -> пробрасываем TON в поле Send / USDT(TON) в поле Receive + Кнопка Swap на экране токена -> пробрасываем токен в поле Send / TON в поле Receive + Кнопка Swap на экране USDT TRC20 -> пока что по-прежнему открывает LetsExchange + Кнопка Swap на экране USDe -> пробрасываем USDe в поле Send / USDT(TON) в поле Receive + Кнопка Swap на экране tsUSDe -> пробрасываем tsUSDe в поле Send / USDe в поле Receive + Экран Swap + Можно вписать сумму в поля Send/Receive + Знак примерно всегда в поле Received независимо от того, куда вводили значение + Пока ждем ответ от омнистона -> крутится лоадер на кнопке Continue + показываем наш курс, но серым и с анимацией + Как только получили ответ -> лоадер пропадает + показываем курс от омнистона белым как обычно + По тапу на Continue + передаётся в любом случае значение из поля Send + клиент отписывается от получения новых событий о свопе + В инпуте Send вместо поля Balance рисуем Remaining: N TON / Осталось: N TON + Переключалка между Send/Receive целиком меняет их местами (сумма + токен) + Можно проставить максимум. +Надо, но пока не сделали: Для пары TON-токен -> максимум будет уменьшен на сумму комиссии + Выбор токена (сейчас свопы только внутри тона, в связи с этим): + Пока не рисуем надписи TON assets/Other crypto + Все токены показываем одним списком на экране, ничего не сворачиваем + Для Send: возле токена показываем его баланс, сортировка - сначала TON, потом по балансу сверху вниз + Для Receive: возле токена показываем его полное название, сортировка - как пришло с бэка + Галочка возле текущего выбранного токена + пролистываем к этому токену + Работает поиск по токену: + Пока не начали поиск - просто показываем список всех токенов + Поиск начинаем с одного символа + Заглушка, если ничего не найдено + Отображаем примерный курс обмена (две строчки) + Отображаем дисклеймер на ston.fi + Если дисклеймер перекрывает курс -> появляется сепаратор (кнопка Прололжить и дисклеймер вместе на одной подложке) + Экран Confirm Swap + По тапу на кнопку назад - возвращаем на экран Swap, все введенные данные сохраняем + По тапу на поля Send/Receive - возвращаем на экран Swap, фокус на том поле, по которому тапали (тыкали на Receive - будет фокус на Receive) + По тапу на информационную иконку + заголовок показываем тост с текстовкой-объяснением. Не делали поля с иконками + По тапу на Edit у Fee - показываем выбиралку комисссии (TON / батарейка). Надпись Edit всегда синего цвета. + Cлайдер для конфирма + На слайдера есть таймер-кружочек, который показывает сколько осталось до подписания транзакции (протухает предлоджние от провайдера) + По истечении этого времени -> возвращает на экран назад + После подтверждения - стандартный экран успеха, затем пробрасываем в историю общую или конкретного токена (смотря откуда точка входа) + Провайдер всегда рисуется Omniston (значение передается с бэка) + Add Funds + Receive tokens + Открывает экран Your address + Можно переключаться табами между TON и TRC20 адресами + Если нет TRC20 на балансе, то при первом переходе на вкладку TRC20, предлагается его включение + Адрес сети показан корректно + В QR коде зашит диплинк с корректным адресом + ton://transfer/{{address}} для TON + {{address}} для TRON + ton://transfer/{{address}}&jetton={{jetton_master}} для других TON токенов (доступно с экрана токена) + Можно скопировать адрес тапом на QR, адрес или Copy + Можно поделиться адресом в telegram + Buy with Google Pay + Buy with Google Pay if region != RU + Buy with P2P Market if region = RU + Hide if region = IR + P2P недоступен в странах из списка во вложении + Choose asset + Поиск работает корректно + Доступны к депозиту TON, USDT TON или USDT TRC20 + Payment Method + По дефолту выбрана валюта тонкипера + Перечислены доступные методы (Google Pay, P2P, PayPal, etc) + Можно сменить валюту -> меняются доступные методы + Все методы оплаты кроме P2P открывают экран Enter Amount + По дефолту проставлено минимальная сумма в фиате + Переключался фиат <-> крипта пока не сделали + До дефолту выбран BEST провайдер + По тапу на провайдера открывается экран выбора провайдера + Можно сменить на другого, если есть + Показаны другие доступные провайдеры с бОльшей минимальной суммой (подсвечено оранжевым) + При тапе на провайдера с бОльшей минимальной суммой, минимальная сумма автоматически заполняется в инпут и меняется провайдер + Корректно отображается курс + Есть проверка если ввести меньше минимума + Есть проверка если ввести больше максимума + Перед переходом в онрамп и P2P есть лигал боттомшит + Можно скипнуть лигал боттомшит, поставив галочку "Больше не показывать" + В онрамп прокидывается сумма, пара фиатная валюта-крипто ассет и адрес и метод оплаты + P2P ведет в Wallet P2P Express + В Wallet P2P Express прокидывается сумма и пара фиатная валюта-крипто ассет + Buy TON with Сrypto + На экране Payment method отображается список доступных ассетов (список пока ограниченный, только самы популярные) + После выбора ассета открывается экран Send {{asset}} + Корректно отображается Send {{asset}} / Receive {{asset}} + Корректно отображается курс + Корректно отображаестя адрес сети, куда отправляем ассеты + Адрес можно скопировать + Можно отсканировать адрес с QR кода + Можно поделиться в телеграм + Корректно указаны min, max amount, network, set, arrival time + Ссылка на ToU Changelly + Кнопка Go to Main открывает главный экран тонкипера + Покупка TON проходит по указанному курсу Changelly + Buy USDT with Stablecoins + Экран Choose assets + Работает поиск + Можно выбрать USDT TON + Можно выбрать USDT TRC20 + Payment method (DAI, USDT, USDC) + Choose network + Экран Send {{asset}} работает аналогично этому экрану в секции "Buy TON with Сrypto" + Покупка Stablecoins всегда проходит 1:1 без комиссии + Withdraw + Send tokens to another TON/TRON wallet + Открывается флоу Send TON/USDT TRC20 + Sell to card + Asset to withdraw + Работает поиск + Доступны к депозиту TON, USDT TON или USDT TRC20 + Receive method + По дефолту выбрана preffered_currency c бэка, фолбэк на фиатную валюту приложения тонкипер + Отображаются доступные методы вывода для этой валюты + Можно сменить валюту + При смене валюты доступные методы вывода актуализируются + Enter amount работает аналогично Enter amount в Add Funds + Выбор Provider работет аналогично флоу Add Funds + Есть лигал боттомшит перед открытием онрампа и P2P + В онрамп прокидывается сумма, пара фиатная валюта-крипто ассет и адрес и метод оплаты + P2P сразу ведет в Wallet P2P Express + В Wallet P2P Express прокидывается сумма и пара фиатная валюта-крипто ассет + Get USDT on other networks + Флоу работает аналогично Add funds + Stake Screen + Точка входа: Экран кошелёк → Stake + Отображается экран застейкать и выбор стейкинга + По дефолту выбран наиболее прибыльный стейкинг + Есть проверка на минимальную сумму ввода + Есть проверка доступного баланса + Кнопка продолжить задизейблена пока не введена валидная сумма + Выбор пула + По тапу на выбор стейкинга можно выбрать любой доступный пуп (Ликвидный - Tonstakers; Другое - TON Whales, TON Nominators) + Отображается плашка MAX APY на самом прибыльном пуле + Указазана минимальная сумма пополнения + + Tonstakers + Особенность: после отправки депозита TON, на баланс прилетает эквивалентное кол-во tsTON токенов + tsTON можно отправить/свапнуть как обычный токен + С экрана пула → tsTON + Обычное флоу отправки → выбрать tsTON в списке токенов + + Ton Whales и TON Nominators + Whales: по дефолту отображаются только Tonkeeper Queue#1 и Tonkeeper Queue#2 + Другие пулы Whales отображаются, только если там есть средства + TON Nominators: по дефолту все доступные пулы + Отсортированы по APY + + Экран пула + Точка входа: Главный экран -> список активов, если есть активный стейкинг + Депозит - "Внести" + Tonstakers: минимум 2.2 TON (от 1 TON можем застейкать; 1,2 TON заставляем оставить на балансе для покрытия комиссий) [проверки на комиссию 1,2 TON еще не сделали] + Whales: минимум 50 TON + TON Nominators: миминум 10 001 TON в зависимости от условий пула + Стоимость застейканных средств отображается в TON и фиате рядом с названием пула (в списке токенов, на экране пула) + Для запроса на ввод на Nominators проставляется текстовый коммент "d" + Запрос на вывод - "Вывести" + Отображается информация о следующем цикле, время изменяется в реалтайме + Можно сделать запрос на вывод как части, так и всех средств (TON Whales; Tonstakers) + C Nominators можно вывести только всё сразу + Для запросы вывода c Nominators проставляется текстовый коммент "w" + Вывод переходит в статус "Ожидают вывод" + на главном экране отображается плашка с суммой вывода в TON и таймер (кроме Tonstakers) + на экране пула отображается плашка с сумма вывода и информация "после окончания цикла" + Вывод готов + Tonstakers -> TON сами прилетают на баланс + Ton Whales и TON Nominators - > Плашка "Вывод готов" -> Вывод проходит успешно + + +Hidden amounts + Включается тапом на общий баланс на главном экране + Все балансы, суммы в истории, пересчеты в фиат, названия NFT заменяются на *** + Иконки NFT заблюрены + +Manage tokens/NFT + Управление жетонами + По умолчанию отображаем (visible) токены с флагом "verification": "whitelist" или "none" + В истории транзакции с токеном "verification": "blacklist" имеют тикер SPAM / на главном экране тикер FAKE [баг] + Visible токены отображаются на экране Кошелёк, учитываются в общем балансе и отображаются в истории транзакций + Hidden токены отображаются ТОЛЬКО на экране "Управление токенами" + Токены можно закреплять и менять положение закрепленных токенов + TON всегда первый и не скрывается. USDT закреплен первым, но можно открепить/перетащить + Управление NFT + Отображаем (visible) NFT с флагом trust: whitelist/graylist/none + NFT с флагом trust: blacklist всегда скрыты + Visible NFT отображаются на экране Collectibles + Hidden NFT(которые пользователь скрыл сам) и Spam NFT (которые пользователь пометил как Спам) отображаются ТОЛЬКО на экране "Управление NFT" + +Scam / Spam + NFT можно Пожаловаться + Для trust: graylist/none есть кнопки "Скрыть и пожаловаться" в меню "три точки" + Для trust: none на экране NFT есть: + Оранжевая кнопка Пожаловаться -> NFT исчезает из списка + Серая кнопка "Это не спам" -> NFT остаестся в списке, но Скрыть и пожаловаться все еще можно из меню "три точки" + На trust: whitelist нельзя пожаловаться + Можно пожаловаться на входящую транзакцию в истории из деталей транзакции или из меню три точки + Транзакции меньше 0,03 TON с комментарием + Жетон стоимостью < 0,03 TON или без стоимости с комментарием + Непроверенный NFT с комментарием + При отправке в спам на бэк отправляется репорт + Транзакции перемещается в папку Спам + Можно вернуть транзакцию из папки Спам + Для вотч онли скрываем кнопку Пожаловаться + +History Screen + У пользователя без транзакций отображается заглушка с кнопками "Купить" и "Получить" + Если транзакциям меньше месяца - группируются по дням, больше - по месяцам, больше - по годам + Подгружаются все старые транзакции TON и TRON + Все связанные транзакции разбиты на блоки, как на tonapi.io + Все транзакции появляются в истории в фоне - сначала транзакция в ожидании (с лоадером), потом меняется на завершенную (без лоадера) + У транзакций отображаются иконка, сумма, тикер, адрес отправителя/получателя, комментарий + Можно отфильтровать историю по Получено или Отправлено + Фильтр сбрасывается после перезахода в апп или повторным нажатием на выбранный фильтр + Рядом с фильтрами есть папка Спам. В ней все спам транзакции с бэка + те, что юзер отметил как спам + + Боттомшит транзакции (Детали транзакции) + Отображаются сумма, пересчет в фиат, дата/время совершения транзакции + Отображаются отправитель/получатель, комиссия + пересчет в фиат, комментарий + Все поля можно копировать + По тапу на "Транзакции {{hash}}" или из трех точек -> открывается текущая транзакция в tonviewer.com в chrome custom tabs + По тапу на изображение NFT в истории/деталях, открывается экран NFT + На спам транзакциях есть бейдж SPAM, комментарий скрыт, иконка NFT скрыта + Все комментарии режем на первой строчке в истории + У неуспешных транзакций отображается иконка ошибки (!), подпись "Неуспешно", контент для NFT скрыт + +Browser Screen + Таб Explore + Отображается каруселька из баннеров, скроллится сама и руками, по тапу на баннер открывается сервис + Отображается список рекомендованных сервисов, каждый блок рекомендаций можно раскрыть подробнее по кнопке All + Таб Connected + Отображаются подключенные сервисы: можно открыть сервис/отключить + Есть заглушка, если нет Connected Dapps + Внутренний браузер + Вертикальный скролл корректно работает + Работает pull-to-refresh + Корректно работает переход между страницами сервисов + Корректно работает переадресация в телеграм + Корректно работает авторизация в даппе через телеграмм + Поиск/Адресная строка + Поисковые запросы открываются в выбранной на экране Настройки поисковой машине + Открываются введенные ссылки + Открываются сайты .ton [не сделано] + +Collectibles Screen + Список NFT отображается корректно, со всеми картинками + NFT отсортированы по последнему взаимодействию + Экран NFT + Название, изображение, описание + Аппрувнутые коллекции NFT имеют иконку галочку рядом с названием + Кнопка "Свернуть" + Кнопка "Отправить" + Три точки -> "Открыть в Tonviewer" + Три точки -> "Скрыть коллекцию" + Три точки -> "Скрыть и пожаловаться" для Непроверенных нфт + Три точки -> "Сжечь" для Непроверенных NFT -> отправляет на "нулевой" адрес + Флоу отправки NFT работает аналогично отправке TON + Programmable NFT + Описания более двух строк сворачиваются под More + Отображается не более 5 дополнительных кнопок + Если коллекция не верифицированная — показываем алерт при каждом тапе по кнопкам [не сделано] + По тапу на кнопку, открывается зашитая в кнопку ссылка во внутреннем браузере + DNS + На экране Collectibles отображается картинка с адресом DNS + На экране Collectibles рисуем огонек, если домен истечет через <=30 дней + На экране DNS NFT есть programmable кнопка Manage -> открывает dns.tonkeeper.com + Линковка домена [не сделано] + Если к DNS не привязан адрес, отображается кнопка Link domain + По тапу на кнопку Link, поднимается боттомшит подтверждения транзакции + По тапу на кнопку Link, поднимается боттомшит подтверждения транзакции + По тапу на кнопку "Replace", поднимается боттомшит "Wallet address" - можно указать любой адрес на который будут приходить деньги по данному домену + После подтверждения транзакции, указанный адрес привязан к DNS. Отображается тост "Address linked" + Если к DNS привязан адрес, отображается кнопка Linked with UQ... + По тапу на кнопку Linked with, поднимается боттомшит отвязки адреса от DNS + После подтверждения отвязки адреса, адрес отвязан, отображается кнопка Link domain. Отображается тост "Address unlinked" + Если домен привязан не к кошельку владельца, отображается желтое предупреждение "The domain is not linked to your current address" + Продление аренды домена + Продление одного домена + Можно продлить аренду домена на 1 год (366 дней) + На кнопке продления указана корректная дата "Продлить до %плюс 1 год от текущей даты%" + Под кнопкой корректно указано через сколько дней истекает владение DNS + После подтверждения транзакции кол-во дней до истечения срока аренды обновляется. Появляется тост "Домен продлен на 1 год" + Продление всех доменов + На главном экране отображается плашка "У вас есть N доменов с истекающим сроком аренды. Продлите все до DATE." Плашка отображается, если есть хотя бы один домен, который истечет через <=30 дней + На экране продления всех доменов отображаются все домены, которые есть у пользователя + Дата истечения срока аренды отображается красным, если осталось <=30 дней + По тапну на кнопку Renew all, открывается окно подтверждения продления для всех доменов + Стоимость продления = dns_count * 0,02 + После завершения продления, окно подтверждения транзакции закрывается, экран продления доменов закрывается, появляется тост "Domains renewed" + TG Username + В общем списке NFT отображаются картинка иконкой телеграмм, именем @name и name.t.me + Линковка адреса TG Username работает по аналогии с DNS > Флоу Link domain [не сделано - сейчас работает как кнопка Manage] + NFT на продаже/аукционе + NFT на продаже/аукционе отмечены бейджом ON SALE в сетке NFT + Кнопки Отправить, Привязать, Продлить не активны + Есть текстовое пояснение, что NFT на продаже + + +Адресная книга + Контакты разбиты на три блока + блок 1: все добавленные кошельки, можно выбрать любой (фильтруются по mainnet/testnet) + Корректно отображаются имя/иконка кошельков + блок 2: контакты, которые добавил пользователь + Корректно отображается имя и иконка "дискета" + блок 3: недавние контакты из истории + Только адреса, на которые я отправлял (исходящие) + Адреса с флагом is_wallet: true (то есть только кошельки, контракты всякие не показываем) + Корректно отображается адрес, имя, иконка "часы" + Можно добавить контакт по тапу на Add to Address Book в "блок 2" + Имя - максимум 32 символа + Можно ввести адрес в формате UQ, EQ, raw, dns, tg username -> отправка будет в соответствующем формате + Есть проверка на невалидные адрес + Разделение адресной книги для мейннета и тестнета + Можно отредактировать имя в списке + Адрес не редактируется + Можно удалить контакт из списка + Можно сохранить адрес из "блока 3" в "блок 2" + Автоматически предлагается имя: адрес в формате 8...8 или dns/tg имя + Можно скрыть адрес из "блока 3" + +Settings Screen + Безопасность + Использовать Face recognition, Fingerprints вкл/выкл + Экран блокировки -> вкл/выкл вход в приложение по пинкоду + Поменять пин код -> старый пин код больше не будет подходить + Блок включения USDT TRC20 + Тогл включает/выключает отображение USDT TRC20 в кошельке (баланс, адрес, секретный ключ) + По умолчанию тогл выключен для новых кошельков и при импорте кошельков без USDT TRC20 на балансе + Если импортировали кошелек с USDT TRC20 на балансе -> тогл включен + Основная валюта + Отображается текущая валюта + Открывается новый экран со списком валют + Меняешь валюту -> меняется везде (главный экран, экран отправки суммы, баланс TON, график) + Оценить приложение -> кидает в стор + Support → opens “Need Help?” + Ask on Telegram → opens the @tonkeeper Telegram bot + Contact by email → opens a mail draft to support@tonkeeper.com + Новости Tonkeeper -> RU/EN локаль (@tonkeeper_ru / @tonkeeper_news) + Юридические документы + +Push notifications + Страничка по пушам и коммерческим пушам - https://www.notion.so/tonapps/QA-ad934bd35aed49cbac0d6c94efb44294 + Предложение Enable notifications + Во время создания кошелька + Во время импорта кошелька + Получение + Пуш для TON (Минимальная сумма транзакции для получения пуш-уведомления 1 тон) + Пуш для жетонов + Пуш для NFT + Отправка + Пуш для TON (любая сумма) + Транзакция от подключенного даппа (ожидает подписания) + Экран Notifications + Можно отключить подписку с бэка на пуши (не влияет на пермишн на телефоне) + Если отключен пермишн на телефоне - тогл задизейблен, желтый блок с предложением открыть настройки и включить пермишн. [не сделано] + При включении пермишна, тогл становится активным, желтый блок исчезает [не сделано] + Включение уведомлений и пушей android без GoogleMobileServices - скрыто + +Tonkeeper Messages [Сделаны только пуши, уведомления от даппа сейчас скрыты] + Дока по отправке Tonkeeper Messages - https://www.notion.so/tonapps/Tonkeeper-Messages-9f6b5cf96aad4ddab5fc919b8ace1b3f + Включение/выключение пушей/уведомлений + При коннекте к даппу есть чекбокс Allow notifications, по дефолту там стоит галочка + Настройки -> Уведомления + Системные пуши с уведомлениями от dApp приходят, если включены в настройках + В системном пуше есть заголовок сервиса и текст уведомления + Disconnect Dapp -> отключает дапп от кошелька. Пуши от даппа больше не приходят + При выходе из кошелька -> пуши/уведомления от даппа отключаются + +Ton Connect 2.0 + Подключение к сервису + Через внутренний браузер приложения + Через mobile web (deeplink) + Через TWA (deeplink) + Через desktop web (QR код) + Коннект работает как по универсальному QR/диплинку (tc://), так и по QR/диплинку тонкипера (https://app.tonkeeper.com/ton-connect ) + После коннекта иконка сервиса появляется в браузере тонкипера в разделе подключенных + Не дисконектит после обновления страницы и если закрыть/открыть приложение + Работает returnStrategy для ссылки https://app.tonkeeper.com/ton-connect или tc:// + Работа с сервисом + Подтверждение действий из внутреннего браузера приложения + Подтверждение действий из mobile web (редирект по диплинку в приложение + автоматическое поднятие боттомшита с транзакцией) + Подтверждение действий из TWA (редирект по диплинку в приложение + автоматическое поднятие боттомшита с транзакцией) + Подтверждение действий из desktop web (автоматическое поднятие боттомшита в открытом приложении) + Также проверяем, что при появлении нового боттомшита c транзакцией или логином -> предыдущий сворачивается + Отключение от сервиса + При дисконнекте dApp соединение с бриджом отключается + Дисконнект из dApp + Иконка подключенного dApp пропадает со страницы браузера (если это было единственное соединение) + Дисконнект из вкладки Браузер + Кнопка Remove и Disconnect в меню "три точки" отключает все имеющиеся соединения с даппом + + Поле Risk на боттомшите тон коннект транзакции + Внизу боттомшита отображается сколько суммарно денег пользователь отправит после совершения данного действия + Если сумма больше 20% всего баланса пользователя -> красим этот текст в желтый + По тапу на "i" отображается объяснение + Также проверить Risk for Jettons + +Battery flow + Главый экран + Отображаем иконку батарейки справа от total balance + Если нет зарядов: внутри молния + Если есть заряды: Батарейка своей заполненностью показывает количество зарядов от 0% до 100% + ≈10% и менее - заливка желтым цветом + Экран Батарейка и пополнение + Точки входа + Батарейка на главном экране + Настройки + Кнопка "Recharge battery" на боттомшите "Недостаточно средств" + Отображается текущая заполненность батарейки от 0% до 100% + Управление действиями за счет батарейки + Если баланс батарейки = 0, можно посмотреть список Поддерживаемых действий + Если батарейка заряжена, можно включить/отключить отправку через батарейку для поддерживаемых действий + Указан прайс в зарядах для каждого действия и соответствует действительности + In-App Purchase + По тапу на кнопку Buy, инициируется in-app покупка + Рядом с тарифом есть информация, что в него входит + Покупка совершена + Появляется тост "Your battery is charged" + Оставляем на экране батарейки, показываем актуальный баланс в зарядах + батарейка плавно заполняется + Показываем аналогичный заряд батарейки на главном экране + Восстановление покупок работает, либо показывает тост "Нечего восстанавливать" + Отправка через батарейку + Сейчас поддерживаем: + Отправка NFT + Отправка жетона + Свап в omnistone внутри Tonkeeper. Только жетон-тон и жетон-жетон + На экране подтверждения отправки указано, что оплата комиссии "Будет оплачена батарейкой" + Корректно указано сколько зарядов нужно и сколько денется + V4: Входящая транзакция от battery.ton → Отправляется исходная транзакция → Остаток зарядов возвращается на батарейку (excess) + W5: То же самое, но все в одном блоке транзакций + Боттомшит "Недостаточно средств" + Появляется, если на балансе недостаточно TON для действия и батарейка не заряжена + Если для действия возможна оплата батарейкой, есть кнопка "Recharge battery" → Открывает экран батарейки с вариантами покупки + Всегда есть кнопка "Buy TON" → Ведет на экран покупки TON + Пополнение батарейки за крипту + Можно пополнить батарейку, отправив USDT | NOT | DOGS | JUSDT | CATI | HMSTR | tsTON | TON на адрес battery.ton + Можно зарядить батарейку другому кошельку, если в комментарии указать адрес EQ/UQ + Или через интерфейс на экране батарейки + Сортировка крипты: USDT всегда первый, TON последний, остальное как отдает бэк + Показывает опцию зарядки USDT и TON всегда + Показывает опцию зарядки остальной криптой, только если она есть на балансе + Есть тарифные пакеты с фиксированными суммами (small, medium, large) + Корректные характеристики (i) к каждому тарифу + Можно ввести произвольную сумму + При первом пополнении учитывается сумма резерва + При пополнении "в кредит" есть проверка на минимальную сумма пополнения + При заряде батарейки токенами (не TON) даем кредит на транзакцию. Но не даем при отрицательном разряде батарейки + Можно зарядить батарейку другому кошельку - "Батарейка в подарок" + Если зарядить на адрес сети TON EQ/UQ/dns + Можно посмотреть все покупки через кнопку Charge History + Можно сделать возврат покупки за крипту в течение 14 дней + Когда батарейка разряжена в 0, кошелёк сам переключается на отправку за счет баланса в TON + Применение реферального промо кода + Можно ввести/вставить промо код в интуп "Промокод" + Можно перейти по диплинку / QR с промо-кодом и он проставится + Если промо валидный -> рядом появляется иконка зеленой галочки + Валидный промо код сохраняется при перезаходе на экран / в апп + Если промо невалидный -> тост с ошибкой + поле подсвечивается красным -> промо не сохраняется при перезаходе на экран + Промо привязываем к покупке + Если зарядка за крипту, то промо передаётся в транзакции. Там теперь бинарный пэйлоад, а не комментарий + Если IAP покупка, то в post методе есть дополнительное поле promo + +Gasless flow + Можно отправить токены USDT | NOT | DOGS | BOLT | TON и др. и расплатиться за комиссию ими же + Если заряжена батарейка, то в первую очередь предлагает расплатиться ей + Если батарейка НЕ заряжена + И если есть TON на балансе, то можно выбрать отправить за TON или gasless + И если нет TON на балансе, то сразу предлагает отправить за gasless + И если нет нет TON на балансе И недостаточно токенов на gasless, то показывает боттомшит Недостаточно средств + Gasless корректно работает и при отправке всего баланса (MAX) + + Сторисы + Умеем открывать сторис с бэка + На старте приложения + По диплинку https://app.tonkeeper.com/story/{{story_id}} + + Сканирование QR кода + Можно отсканировать QR код с адресом или диплинком + камерой девайса + с главного экрана + из флоу отправки + если нет прав - отображается заглушка с просьбой дать права на устройстве + На экране сканер работает скан QR из галереи, распознаются только скриншот (не фото) + +Deeplinks + https://mois-ilya.github.io/ton-wallet-deeplinks/#/tests + +Оформление + Можно выбрать тему: Тёмно-синяя, Тёмная, Светлая, Системная + Тёмно-синяя по дефолту + Системная тёмная = Тёмная / Системная светлая = Светлая + Можно выбрать иконку: Default, Accent, Dark, Light + Default иконка по дефолту + Можно включить тогл для применения Стандартного размера шрифта -> игнорирует настройки шрифта девайса + +Виджет + TODO + diff --git a/.codex/skills/tk-impact-analisys/sources/Android/regress_EN.txt b/.codex/skills/tk-impact-analisys/sources/Android/regress_EN.txt new file mode 100644 index 000000000..7e8fa86ed --- /dev/null +++ b/.codex/skills/tk-impact-analisys/sources/Android/regress_EN.txt @@ -0,0 +1,771 @@ +Important base things + The app works correctly in Airplane Mode with WI-FI enabled + Internet disconnection + The "No connection" banner appears, disappears when the internet appears + Balances/rates/history that were current at the last moment of internet access are displayed + Under the total balance it says "Updated on {{date, time}}" + The build size did not increase, or increased only very slightly + The default localization language depends on the language on the device + If the app was installed from an apk from the website -> the update flow works directly in the wallet when a new apk version is released + +On the screen "Create a new wallet or add an existing one" there is a link to Tonkeeper ToU +Create/Import wallet + Wallet creation + It is required to set a four-digit PIN code + Back up + The Continue button starts the backup flow + On the "Secret key" screen all 24 words with their numbers are always fully visible + The Continue button leads opens the screen for checking three words + Three random words out of 24 are requested + Until all fields are filled in correctly, the "Continue" button is disabled + When switching between the "Secret key" and "Backup check" screens, the key values do not change + When returning to the seed phrase screen, the entered words are erased + While on the "Backup check" screen, it is impossible to "peek" the seed phrase with a swipe + The later button skips Back up + Access to push notifications is requested + It is possible to customize the wallet (name, icon, color) + By default the name is "Tonkeeper", the color is "dark gray", the icon is "wallet" + Upon completion of creation, the Wallet screen opens (with the w5 wallet) + The Finish setting up block is displayed + Together with the first outgoing transaction, wallet Initialization occurs + + Import TON wallet + It is possible to enter a secret key of 24 or 12 words + 24 words are selected by default + If there are 12 words in the clipboard -> 12 words are preselected + Hints above the keyboard when entering a word + It is possible to paste all the words as a list + Field validation when moving to the next field + The Continue button is inactive if at least one of the fields has a validation error/the word is entered incorrectly + There is a secret phrase checksum check -> Error "Wrong key" + We show an error for the mnemonic for a multi-account "Please use Tonkeeper Desktop ..." + The "Choose wallets" screen appears with all wallet versions that have funds + w5 always + If old versions do not have funds, we skip the "Choose wallets" screen + It is required to set a four-digit PIN to confirm payments + Access to push notifications is requested + It is possible to customize the wallet (name, icon, color) + The Finish setting up block is displayed (without the "backup" item) + Import TRON wallet + It is possible to enter a secret key of 12 words (the flow is similar to the TON import flow) + + + Watch only + It is possible to add a wallet by address/DNS + It is possible to subscribe to push notifications + On the main screen only the "Deposit" button is available. Inside only Receive tokens, all onramps are hidden + There is a Watch only badge in the wallet list, on the Receive screen + When copying the address, the toast color is orange + Battery, seed phrase are hidden + When connecting to ton connect, it is impossible to choose watch only from the wallet list (it seems now it can be returned, check?) + Watch only can be connected to dapps without ton proof + A bottomsheet with an explanation that this is watch only and a suggestion to enter the seed phrase pops up: + When adding a wallet + When tapping disabled buttons on the main screen + When trying to connect to a dapp with ton proof (was not done, but apparently should) + When trying to make a transaction, if connected without ton proof + + Testnet + It is possible to add by seed phrase + On the main screen only the "Withdraw", "Deposit" buttons are available. Inside only sending/receiving, all onramps are hidden + There is a Testnet badge in the wallet list + When copying the address, the toast color is orange + Transactions are sent in the Testnet network + There is a check if a Mainnet address was entered + + Signer + It is possible to connect Signer and Tonkeeper on one device by deeplink + It is possible to connect Signer and Tonkeeper on different devices by QR + There is a Signer badge next to the address on the main screen, in the wallet list + Battery, Back up, Gasless, TRON address generation are hidden + For onramps/exchange, USDT TRC20 deposit/withdrawal is hidden (because the address cannot be passed) + It is possible to send a regular TON, Jetton, NFT transaction (fee in TON) + It is possible to connect to a DApp WITHOUT tonproof and sign a ton connect transaction + It is possible to make a native omniston swap (Swap button on the main screen) + It is possible to confirm stake deposit/withdrawal + It is possible to renew DNS, TG Username + + Ledger + Connects by USB + Connects by Bluetooth + Generates 10 addresses by default + There is a Ledger badge next to the address on the main screen, in the wallet list + Battery, Back up, Gasless, TRON address generation are hidden + For onramps/exchange, USDT TRC20 deposit/withdrawal is hidden (because the address cannot be passed) + It is possible to send a regular TON, Jetton, NFT transaction (fee in TON) + It is possible to connect to a DApp with tonproof and sign a ton connect transaction + It is possible to make a native omniston swap (Swap button on the main screen) + It is possible to confirm stake deposit/withdrawal + It is possible to renew DNS, TG Username + + Keystone + Connects by QR + There is a Keystone badge next to the address on the main screen, in the wallet list, on the Receive screen + Battery, Back up, Gasless, TRON address generation are hidden + For onramps/exchange, USDT TRC20 deposit/withdrawal is hidden (because the address cannot be passed) + It is possible to send a regular TON, Jetton, NFT transaction (fee in TON) + It is possible to connect to a DApp with tonproof and sign a ton connect transaction + It is possible to make a native omniston swap (Swap button on the main screen) + It is possible to confirm stake deposit/withdrawal + It is possible to renew DNS, TG Username + + Tetra + Tetra L2 can be enabled in the dev menu + On the main screen only the "Withdraw", "Deposit" buttons are available. Inside only sending/receiving, all onramps are hidden + The option Import Tetra L2 wallet appears in Add wallet + The wallet is successfully imported by 24 words (TON seed phrase) + The wallet is successfully imported by 12 words (TON seed phrase) + It is possible to import w5 and v4r2 versions, it makes no sense to check earlier ones + The address is displayed as UQ for now + It is possible to bridge TON from TON Mainet to Tetra Chain + It is possible to bridge TON back from Tetra Chain to TON Mainet + The TON rate is pulled https://tetra.tonapi.io/v2/rates?tokens=ton¤cies=usd + There are TETRA badges on the main screen, in the Add wallet list, on the Receive screen + All extra functionality can be hidden by flags in /keys + Battery + Swap + Onramps + Staking + Stories about onboarding + Ethena (USDe) should not pop up by default in the asset list on the main screen + Collectibles (NFT) hide or it is enough to leave a stub, as when there are none on the balance + Promoted dapps in the browser + In history in transactions the address format is UQ/EQ + The transaction link leads to tetra.tonviewer.com + + + When adding subsequent wallets, it asks to enter a pin code/biometrics + +Backup wallet + Finish setting up block + The user created a wallet: Notifications (if they were not enabled during onboarding), Biometrics, Back up (if it was not completed during onboarding), Story "How to use your wallet" + The user imported a wallet: Notifications (if they were not enabled during onboarding), Biometrics, Story "How to use your wallet", Done + If the user made a backup - the Done button appears + When notifications/biometrics are enabled - the toggles change state + After viewing Story "How to use your wallet" - Strory does not disappear + The state of the toggles on the Finish setting up screen matches the states of the same toggles in settings + + Backup screen + User who did not make a backup + The Back Up Manually button is displayed + A red dot on the settings icon and "Backup" until the user makes a backup + User who made a backup + The Manual Backup Done {{date}} and Show recovery phrase buttons are displayed + Tapping any of the buttons displays the Attention screen + Tapping Continue requests password/biometrics + Back Up Manually -> Shows the seed phrase -> Suggests entering three random words from the seed phrase -> The button state changes to Manual Backup Done with the current date + Manual Backup Done -> works similarly to Back Up Manually + Show recovery phrase -> shows the seed phrase and the Copy button + If USDT TRC20 is enabled -> There is an Export TRC20 Wallet button + There is a warning: that this key is only for TRC20 + It is possible to copy the key + User with money on the balance + without backup + We color the balance on the main screen: from 2 TON -> orange, from 20 TON -> red + We show an informational icon that leads to the "Backup" screen + On the Backup screen there is a colored block with an explanation: the color matches the color of the amount + + Confirmation of the saved seed phrase + Settings -> "Log out of wallet" + The "I have a backup copy" block with a checkbox + "Make a backup copy" leads to the Backup screen + The "Log out"/"Delete" button becomes active when the checkbox is pressed + Not relevant for wallets without a seed phrase: signer, keystone, watch only - we just delete them + +Wallet Screen + The correct amount of all funds in fiat is displayed (including staking) + The wallet address is displayed, it can be copied + Tokens list + At the top of the list Toncoin, USDT (TON/TRC20), USDe, below them - Staked, if something is staked + The token list is sorted by balance and the verified sign + For all tokens the icon, name, ticker, amount must be displayed + If there is a rate: current rate, growth/decrease, conversion to fiat + Orange label for Unverified tokens + Unverified tokens are not counted in the total balance + Token screen + The correct balance is displayed, {{decimals}} decimal places + If possible, the current token price and the conversion of the current amount to fiat are displayed + By tap on the "..." button -> "Details", the explorer opens + By tap on the "..." button -> "Widget", a widget with the token rate is created on the device home screen + By tap on the "..." button -> "Burn", sends Unverified tokens to the "zero" address (displayed only for Unverified tokens) + The Send/Receive/Swap buttons work as on the main screen, the current token is substituted + The list of transactions is displayed as on the History screen, but sorted by token + Price chart + The currency rate chart is displayed + The user can tap the chart and move the point with the date along the chart. At the same time, the value and the value change will change for the day selected by the user, and the "price" will change to the selected date + The default rate change interval is 1 month + The rate change interval selected by the user must be preserved even after exiting the app + There is a stub when there is no internet [not done yet] + Unverified token + Works for all tokens with the "verification" sign: “none” + On all screens (Wallet, History, Manage tokens, transaction details) - the orange Unverified token label is displayed + On the token screen the orange Unverified token label and the icon ( i ) are displayed next to it + By tap on "Unverified token ( i )" on the transaction jetton screen an explanation opens + + Send TON + ctrl+c/ctrl+v works in all fields + The entire sending flow screens are rewritten by swipe right, the back and close buttons work correctly + Send screen + Address + It is possible to enter [TON wallet address] OR [dns] OR [tg username] + The "Paste", "Clear field" buttons work + Validation works if the address field is filled incorrectly + Validation works for an address of another network, including from mainnet to testnet and vice versa + Validation works for a scam address + It is possible to choose any address from the address book + Amount + Currency selection + TON currency is displayed by default + If the entry point was the token screen, the selected token is displayed for sending + It is possible to change the sending currency on the amount input screen + Reset the amount when switching the token if it was previously entered + When switching to a token with a different number of decimal places, verify that the correct amount is sent + "Amount" field + It is possible to enter integer and fractional numbers in the send amount input field. It is possible to enter a maximum of 24 characters in the amount for the fractional and integer parts (including the separator). At the same time, after the decimal point a maximum of as many digits as the token decimals (TON = 9, USDT = 6) + Validation error when entering > current balance + It is possible to enter the entire balance by pressing "Maximum" or by hand + The "Available:" value decreases after entering the amount + It is possible to specify the amount in fiat, there is a swap button to switch + By tap on "Continue", the transaction confirmation screen opens + The "Continue" button is not active until the "Address or domain" and "Amount" fields are filled + Comment + It is possible to enter letters/numbers/special characters/emojis + There is no limit on the number of characters in the comment + If the transfer is made to an exchange address - an orange warning is on, it is impossible to go to the next screen + Confirm transaction screen + The name of the wallet you are sending from is displayed + If transferring to a favorite contact/by dns - the "Recipient" field with the name is displayed + For tokens - the sending amount is indicated in tokens, the fee in tons + For all amounts the conversion to fiat is displayed + The recipient address, transfer amount and fee are displayed + The address and dns name of the recipient can be copied + If "Send all" was indicated, before entering the pin code a warning appears "Are you sure you want to send all tour balance?". The sending amount + Fee = the amount of the total balance. + After tap on "Confirm and send", the confirmation screen by pin code or biometrics opens + After entering the pin code or biometrics, the History screen opens (similarly for other transactions) + Encrypted comments + Sending (ton, token, nft) + Under the comment field the Encrypt button is displayed + By tap on Ecrypt, the Encrypt -> Decrypt button changes, the comment placeholder changes Comment -> Encrypted comment, the frame around the comment changes from blue to green + By tap on Decrypt, everything returns to the original state + When sending to an exchange, the Encrypt button is not displayed (so as not to encrypt the memo) + When sending an encrypted comment, on the Confirm action screen near the comment field we draw a green lock + Transaction history + Encrypted comments are displayed as a bubble with a green lock icon + encrypted comment + On the first attempt to decrypt, a bottomsheet rises with a description of what encrypted messages are. It is possible to leave it or ask not to show it anymore + By tap on the comment bubble in history -> request password/biometrics -> decrypt only this comment + By tap on a transaction cell with an encrypted comment -> the transaction screen opens -> it is possible to decrypt the comment from this bottomsheet + + Server status + Before sending a transaction, the app requests v2/status from tonapi + If rest_online=false or indexing_latency>= 271 sec, it is impossible to send the transaction -> the error "Sending error, try again later" / "Ошибка отправки. Попробуйте позже" appears + + Send USDT TRC20 + It is possible to enter/scan/paste a TRON wallet address + It is possible to send only USDT TRC20 there, the flow is similar to Send Money TON + USDT TRC20 can be sent for battery, TON and TRX + Attempt to send TON tokens to a TRON address + When trying to send USDT TON to a TRON address -> the address is highlighted red; warning with a suggestion to change c2c TON-TRON to Letsexchange; it is impossible to send + When trying to send other TON tokens -> the address is highlighted red; red warning that the address is of the TRON network, it is impossible to send + Attempt to send USDT TRC20 to a TRON address + When trying to send USDT TRC20 to a TON address -> the address is highlighted red; warning with a suggestion to change c2c TRON-TON to Letsexchange; it is impossible to send + + Not enough money on the balance to perform an action / transaction + The "Not enough funds" bottomsheet is displayed + The stub correctly indicates how much funds are required and the current balance + The gray "Buy TON"" button opens the "Buy TON" bottomsheet with a list of available exchanges + The gray "Buy {{token ticker}}"" button opens the DeFi section in the browser + + Swap Omniston + Entry point + Swap button on the main screen -> we pass TON into the Send field / USDT(TON) into the Receive field + Swap button on the token screen -> we pass the token into the Send field / TON into the Receive field + Swap button on the USDT TRC20 screen -> for now still opens LetsExchange + Swap button on the USDe screen -> we pass USDe into the Send field / USDT(TON) into the Receive field + Swap button on the tsUSDe screen -> we pass tsUSDe into the Send field / USDe into the Receive field + Swap screen + It is possible to enter an amount into the Send/Receive fields + The approximately sign is always in the Received field regardless of where the value was entered + While waiting for a response from omniston -> the loader spins on the Continue button + we show our rate, but gray and with animation + As soon as we received the response -> the loader disappears + we show the rate from omniston in white as usual + By tap on Continue + in any case the value from the Send field is transmitted + the client unsubscribes from receiving new swap events + In the Send input instead of the Balance field we draw Remaining: N TON / Осталось: N TON + The switcher between Send/Receive completely swaps them (amount + token) + It is possible to set maximum. +It is needed, but not done yet: For the TON-token pair -> the maximum will be reduced by the fee amount + Token selection (now swaps are only inside ton, in connection with this): + For now we do not draw the TON assets/Other crypto labels + We show all tokens in one list on the screen, do not collapse anything + For Send: next to the token we show its balance, sorting - first TON, then by balance from top to bottom + For Receive: next to the token we show its full name, sorting - as it came from the backend + Checkmark next to the current selected token + scroll to this token + Search by token works: + Until the search started - we just show the list of all tokens + We start the search from one character + Stub if nothing was found + We display the approximate exchange rate (two lines) + We display the disclaimer on ston.fi + If the disclaimer overlaps the rate -> a separator appears (the Continue button and disclaimer together on one substrate) + Confirm Swap screen + By tap on the back button - returns to the Swap screen, all entered data is preserved + By tap on the Send/Receive fields - returns to the Swap screen, focus on the field that was tapped (tapped on Receive - focus will be on Receive) + By tap on the informational icon + header we show a toast with the explanatory text. We did not make fields with icons + By tap on Edit at Fee - we show the fee picker (TON / battery). The Edit label is always blue. + Slider for confirmation + There is a timer-circle on the slider that shows how much is left before signing the transaction (the provider's offer expires) + After this time expires -> returns to the previous screen + After confirmation - the standard success screen, then we pass to the general history or of a specific token (depending on where the entry point was) + The provider is always drawn as Omniston (the value is passed from the backend) + Add Funds + Receive tokens + Opens the Your address screen + It is possible to switch tabs between TON and TRC20 addresses + If there is no TRC20 on the balance, then on the first transition to the TRC20 tab, it is suggested to enable it + The network address is shown correctly + The QR code contains a deeplink with the correct address + ton://transfer/{{address}} for TON + {{address}} for TRON + ton://transfer/{{address}}&jetton={{jetton_master}} for other TON tokens (available from the token screen) + It is possible to copy the address by tapping on the QR, address or Copy + It is possible to share the address in telegram + Buy with Google Pay + Buy with Google Pay if region != RU + Buy with P2P Market if region = RU + Hide if region = IR + P2P is unavailable in the countries from the list in the attachment + Choose asset + Search works correctly + TON, USDT TON or USDT TRC20 are available for deposit + Payment Method + Tonkeeper currency is selected by default + Available methods are listed (Google Pay, P2P, PayPal, etc) + It is possible to change the currency -> available methods change + All payment methods except P2P open the Enter Amount screen + The minimum amount in fiat is set by default + The fiat <-> crypto switch has not been done yet + BEST provider is selected by default + By tap on the provider, the provider selection screen opens + It is possible to change to another one, if there is one + Other available providers with a higher minimum amount are shown (highlighted in orange) + By tap on a provider with a higher minimum amount, the minimum amount is automatically filled into the input and the provider changes + The rate is displayed correctly + There is a check if less than the minimum is entered + There is a check if more than the maximum is entered + Before going to onramp and P2P there is a legal bottomsheet + It is possible to skip the legal bottomsheet by checking the "Do not show again" box + The amount, the fiat currency-crypto asset pair and the address and payment method are passed into the onramp + P2P leads to Wallet P2P Express + In Wallet P2P Express the amount and the fiat currency-crypto asset pair are passed + Buy TON with Сrypto + On the Payment method screen the list of available assets is displayed (the list is limited for now, only the most popular ones) + After selecting an asset, the Send {{asset}} screen opens + Send {{asset}} / Receive {{asset}} is displayed correctly + The rate is displayed correctly + The network address to which we send assets is displayed correctly + The address can be copied + It is possible to scan the address from a QR code + It is possible to share in telegram + min, max amount, network, set, arrival time are indicated correctly + Link to Changelly ToU + The Go to Main button opens the main tonkeeper screen + TON purchase goes through at the specified Changelly rate + Buy USDT with Stablecoins + Choose assets screen + Search works + It is possible to choose USDT TON + It is possible to choose USDT TRC20 + Payment method (DAI, USDT, USDC) + Choose network + The Send {{asset}} screen works similarly to this screen in the "Buy TON with Сrypto" section + Stablecoins purchase always goes 1:1 without fee + Withdraw + Send tokens to another TON/TRON wallet + The Send TON/USDT TRC20 flow opens + Sell to card + Asset to withdraw + Search works + TON, USDT TON or USDT TRC20 are available for deposit + Receive method + preffered_currency from the backend is selected by default, fallback to the fiat currency of the tonkeeper app + Available withdrawal methods for this currency are displayed + It is possible to change the currency + When changing the currency, the available withdrawal methods are updated + Enter amount works similarly to Enter amount in Add Funds + Provider selection works similarly to the Add Funds flow + There is a legal bottomsheet before opening the onramp and P2P + The amount, the fiat currency-crypto asset pair and the address and payment method are passed into the onramp + P2P immediately leads to Wallet P2P Express + In Wallet P2P Express the amount and the fiat currency-crypto asset pair are passed + Get USDT on other networks + The flow works similarly to Add funds + Stake Screen + Entry point: Wallet screen → Stake + The stake screen and staking selection are displayed + The most profitable staking is selected by default + There is a check for the minimum input amount + There is a check of the available balance + The continue button is disabled until a valid amount is entered + Pool selection + By tap on staking selection it is possible to choose any available poop (Liquid - Tonstakers; Other - TON Whales, TON Nominators) + The MAX APY plate is displayed on the most profitable pool + The minimum replenishment amount is indicated + + Tonstakers + Feature: after sending a TON deposit, an equivalent amount of tsTON tokens arrives on the balance + tsTON can be sent/swapped like a regular token + From the pool screen → tsTON + Standard sending flow → choose tsTON in the token list + + Ton Whales and TON Nominators + Whales: only Tonkeeper Queue#1 and Tonkeeper Queue#2 are displayed by default + Other Whales pools are displayed only if there are funds there + TON Nominators: all available pools by default + Sorted by APY + + Pool screen + Entry point: Main screen -> asset list, if there is active staking + Deposit - "Deposit" + Tonstakers: minimum 2.2 TON (from 1 TON we can stake; 1.2 TON we force to leave on the balance to cover fees) [the 1.2 TON fee checks have not been done yet] + Whales: minimum 50 TON + TON Nominators: minimum 10 001 TON depending on the pool conditions + The value of staked funds is displayed in TON and fiat next to the pool name (in the token list, on the pool screen) + For a deposit request on Nominators the text comment "d" is set + Withdrawal request - "Withdraw" + Information about the next cycle is displayed, the time changes in real time + It is possible to make a withdrawal request for part or all funds (TON Whales; Tonstakers) + From Nominators it is possible to withdraw only everything at once + For withdrawal requests from Nominators the text comment "w" is set + Withdrawal goes into the "Awaiting withdrawal" status + on the main screen a plate with the withdrawal amount in TON and a timer is displayed (except Tonstakers) + on the pool screen a plate with the withdrawal amount and the information "after the end of the cycle" is displayed + Withdrawal ready + Tonstakers -> TON arrive on the balance themselves + Ton Whales and TON Nominators - > "Withdrawal ready" plate -> Withdrawal is successful + + +Hidden amounts + Enabled by tap on the total balance on the main screen + All balances, amounts in history, conversions to fiat, NFT names are replaced with *** + NFT icons are blurred + +Manage tokens/NFT + Manage tokens + By default we display (visible) tokens with the "verification": "whitelist" or "none" flag + In history transactions with a token with "verification": "blacklist" have the SPAM ticker / on the main screen the FAKE ticker [bug] + Visible tokens are displayed on the Wallet screen, are counted in the total balance and are displayed in the transaction history + Hidden tokens are displayed ONLY on the "Manage tokens" screen + Tokens can be pinned and the position of pinned tokens can be changed + TON is always first and is not hidden. USDT is pinned first, but it can be unpinned/dragged + Manage NFT + We display (visible) NFT with the trust: whitelist/graylist/none flag + NFT with the trust: blacklist flag are always hidden + Visible NFT are displayed on the Collectibles screen + Hidden NFT (that the user hid himself) and Spam NFT (that the user marked as Spam) are displayed ONLY on the "Manage NFT" screen + +Scam / Spam + NFT can be Reported + For trust: graylist/none there are the "Hide and report" buttons in the "three dots" menu + For trust: none on the NFT screen there are: + Orange Report button -> NFT disappears from the list + Gray "This is not spam" button -> NFT remains in the list, but Hide and report can still be done from the "three dots" menu + For trust: whitelist it is impossible to report + It is possible to report an incoming transaction in history from the transaction details or from the three dots menu + Transactions less than 0.03 TON with a comment + Token worth < 0.03 TON or without value with a comment + Unverified NFT with a comment + When sending to spam, a report is sent to the backend + Transactions are moved to the Spam folder + It is possible to return a transaction from the Spam folder + For watch only we hide the Report button + +History Screen + A user without transactions is displayed a stub with the "Buy" and "Receive" buttons + If transactions are less than a month old - grouped by days, more - by months, more - by years + All old TON and TRON transactions are loaded + All related transactions are split into blocks, like on tonapi.io + All transactions appear in history in the background - first the transaction is pending (with a loader), then changes to completed (without a loader) + Transactions display icon, amount, ticker, sender/recipient address, comment + It is possible to filter history by Received or Sent + The filter resets after re-entering the app or by pressing the selected filter again + There is a Spam folder next to the filters. It contains all spam transactions from the backend + those that the user marked as spam + + Transaction bottomsheet (Transaction details) + Amount, conversion to fiat, date/time of transaction are displayed + Sender/recipient, fee + conversion to fiat, comment are displayed + All fields can be copied + By tap on "Transactions {{hash}}" or from the three dots -> the current transaction opens in tonviewer.com in chrome custom tabs + By tap on the NFT image in history/details, the NFT screen opens + Spam transactions have the SPAM badge, the comment is hidden, the NFT icon is hidden + We cut all comments on the first line in history + Failed transactions display the error icon (!), the "Failed" label, content for NFT is hidden + +Browser Screen + Explore tab + A carousel of banners is displayed, scrolls itself and manually, by tap on the banner the service opens + A list of recommended services is displayed, each recommendation block can be expanded in more detail by the All button + Connected tab + Connected services are displayed: it is possible to open the service/disconnect + There is a stub if there are no Connected Dapps + Internal browser + Vertical scroll works correctly + pull-to-refresh works + Transition between service pages works correctly + Redirect to telegram works correctly + Authorization in a dapp through telegram works correctly + Search/Address bar + Search queries open in the search engine selected on the Settings screen + Entered links open + .ton sites open [not done] + +Collectibles Screen + The NFT list is displayed correctly, with all images + NFT are sorted by the last interaction + NFT screen + Name, image, description + Approved NFT collections have a checkmark icon next to the name + "Collapse" button + "Send" button + Three dots -> "Open in Tonviewer" + Three dots -> "Hide collection" + Three dots -> "Hide and report" for Unverified nft + Three dots -> "Burn" for Unverified NFT -> sends to the "zero" address + NFT sending flow works similarly to TON sending + Programmable NFT + Descriptions longer than two lines are collapsed under More + No more than 5 additional buttons are displayed + If the collection is unverified — we show an alert on every tap on the buttons [not done] + By tap on the button, the link embedded in the button opens in the internal browser + DNS + On the Collectibles screen a picture with the DNS address is displayed + On the Collectibles screen we draw a flame if the domain expires in <=30 days + On the DNS NFT screen there is a programmable Manage button -> opens dns.tonkeeper.com + Domain linking [not done] + If no address is linked to DNS, the Link domain button is displayed + By tap on the Link button, the transaction confirmation bottomsheet rises + By tap on the Link button, the transaction confirmation bottomsheet rises + By tap on the "Replace" button, the "Wallet address" bottomsheet rises - it is possible to specify any address to which money will come for this domain + After confirming the transaction, the specified address is linked to DNS. The "Address linked" toast is displayed + If an address is linked to DNS, the Linked with UQ... button is displayed + By tap on the Linked with button, the address unlinking bottomsheet rises + After confirming unlinking of the address, the address is unlinked, the Link domain button is displayed. The "Address unlinked" toast is displayed + If the domain is linked not to the owner's wallet, the yellow warning "The domain is not linked to your current address" is displayed + Domain rent renewal + Renewal of one domain + It is possible to renew the domain rent for 1 year (366 days) + The renewal button indicates the correct date "Renew until %plus 1 year from the current date%" + Under the button it is correctly indicated in how many days DNS ownership expires + After confirming the transaction the number of days until the rent expiration updates. The toast "Domain renewed for 1 year" appears + Renewal of all domains + On the main screen the plate "You have N domains with expiring rent period. Renew all until DATE." is displayed. The plate is displayed if there is at least one domain that will expire in <=30 days + All domains that the user has are displayed on the all domains renewal screen + The rent expiration date is displayed in red if <=30 days remain + By tap on the Renew all button, a confirmation window for renewal for all domains opens + Renewal cost = dns_count * 0,02 + After renewal is completed, the transaction confirmation window closes, the domain renewal screen closes, the "Domains renewed" toast appears + TG Username + In the general NFT list a picture with the telegram icon, the @name name and name.t.me are displayed + TG Username address linking works similarly to DNS > Link domain Flow [not done - now works as the Manage button] + NFT on sale/auction + NFT on sale/auction are marked with the ON SALE badge in the NFT grid + Send, Link, Renew buttons are inactive + There is a text explanation that the NFT is on sale + + +Address book + Contacts are divided into three blocks + block 1: all added wallets, it is possible to choose any (filtered by mainnet/testnet) + Wallet name/icon are displayed correctly + block 2: contacts added by the user + Name and "floppy disk" icon are displayed correctly + block 3: recent contacts from history + Only addresses to which I sent (outgoing) + Addresses with the is_wallet: true flag (that is, only wallets, various contracts are not shown) + Address, name, "clock" icon are displayed correctly + It is possible to add a contact by tap on Add to Address Book in "block 2" + Name - maximum 32 characters + It is possible to enter an address in UQ, EQ, raw, dns, tg username format -> sending will be in the corresponding format + There is a check for invalid addresses + Separation of the address book for mainnet and testnet + It is possible to edit the name in the list + The address is not editable + It is possible to delete a contact from the list + It is possible to save an address from "block 3" to "block 2" + The name is automatically suggested: address in 8...8 format or dns/tg name + It is possible to hide the address from "block 3" + +Settings Screen + Security + Use Face recognition, Fingerprints on/off + Lock screen -> on/off entering the app by pincode + Change pin code -> the old pin code will no longer fit + USDT TRC20 enable block + The toggle turns on/off the display of USDT TRC20 in the wallet (balance, address, secret key) + By default the toggle is off for new wallets and when importing wallets without USDT TRC20 on the balance + If a wallet with USDT TRC20 on the balance was imported -> the toggle is on + Primary currency + The current currency is displayed + A new screen with a list of currencies opens + Change the currency -> it changes everywhere (main screen, amount sending screen, TON balance, chart) + Rate the app -> throws to the store + Support → opens “Need Help?” + Ask on Telegram → opens the @tonkeeper Telegram bot + Contact by email → opens a mail draft to support@tonkeeper.com + Tonkeeper news -> RU/EN locale (@tonkeeper_ru / @tonkeeper_news) + Legal documents + +Push notifications + Page about pushes and commercial pushes - https://www.notion.so/tonapps/QA-ad934bd35aed49cbac0d6c94efb44294 + Enable notifications offer + During wallet creation + During wallet import + Receiving + Push for TON (Minimum transaction amount for receiving a push notification 1 ton) + Push for tokens + Push for NFT + Sending + Push for TON (any amount) + Transaction from a connected dapp (awaiting signing) + Notifications screen + It is possible to disable subscription from the backend to pushes (does not affect permission on the phone) + If permission on the phone is disabled - the toggle is disabled, yellow block with a suggestion to open settings and enable permission. [not done] + When permission is enabled, the toggle becomes active, the yellow block disappears [not done] + Enabling notifications and pushes android without GoogleMobileServices - hidden + +Tonkeeper Messages [Only pushes are done, notifications from the dapp are hidden now] + Doc on sending Tonkeeper Messages - https://www.notion.so/tonapps/Tonkeeper-Messages-9f6b5cf96aad4ddab5fc919b8ace1b3f + Enabling/disabling pushes/notifications + When connecting to a dapp there is an Allow notifications checkbox, there is a checkmark there by default + Settings -> Notifications + System pushes with notifications from dApp come if enabled in settings + The system push contains the service title and notification text + Disconnect Dapp -> disconnects the dapp from the wallet. Pushes from the dapp no longer come + When logging out of the wallet -> pushes/notifications from the dapp are disabled + +Ton Connect 2.0 + Connection to the service + Through the internal browser of the app + Through mobile web (deeplink) + Through TWA (deeplink) + Through desktop web (QR code) + Connection works both by universal QR/deeplink (tc://), and by Tonkeeper QR/deeplink (https://app.tonkeeper.com/ton-connect ) + After connection the service icon appears in the Tonkeeper browser in the connected section + Does not disconnect after page refresh and if close/open the app + returnStrategy works for the link https://app.tonkeeper.com/ton-connect or tc:// + Working with the service + Confirmation of actions from the internal browser of the app + Confirmation of actions from mobile web (redirect by deeplink to the app + automatic raising of the transaction bottomsheet) + Confirmation of actions from TWA (redirect by deeplink to the app + automatic raising of the transaction bottomsheet) + Confirmation of actions from desktop web (automatic raising of the bottomsheet in the open app) + We also check that when a new bottomsheet with a transaction or login appears -> the previous one collapses + Disconnection from the service + When disconnecting dApp the connection with the bridge is disconnected + Disconnect from dApp + The connected dApp icon disappears from the browser page (if it was the only connection) + Disconnect from the Browser tab + The Remove and Disconnect button in the "three dots" menu disconnects all existing connections with the dapp + + Risk field on the ton connect transaction bottomsheet + At the bottom of the bottomsheet it is displayed how much total money the user will send after performing this action + If the amount is more than 20% of the user's total balance -> color this text yellow + By tap on "i" an explanation is displayed + Also check Risk for Jettons + +Battery flow + Main screen + We display the battery icon to the right of the total balance + If there are no charges: lightning inside + If there are charges: the Battery shows the number of charges from 0% to 100% by its fullness + ≈10% and less - fill with yellow + Battery and top-up screen + Entry points + Battery on the main screen + Settings + "Recharge battery" button on the "Not enough funds" bottomsheet + The current battery fullness from 0% to 100% is displayed + Managing actions at the expense of the battery + If battery balance = 0, it is possible to view the list of Supported actions + If the battery is charged, it is possible to enable/disable sending via battery for supported actions + The price in charges for each action is indicated and corresponds to reality + In-App Purchase + By tap on the Buy button, an in-app purchase is initiated + Next to the tariff there is information about what is included in it + Purchase completed + The "Your battery is charged" toast appears + We stay on the battery screen, show the current balance in charges + the battery smoothly fills up + We show the same battery charge on the main screen + Purchase restoration works, or shows the "Nothing to restore" toast + Sending via battery + Now supported: + Sending NFT + Sending token + Swap in omnistone inside Tonkeeper. Only token-ton and token-token + On the send confirmation screen it is indicated that the fee payment "Will be paid by battery" + It is correctly indicated how many charges are needed and how many will be spent + V4: Incoming transaction from battery.ton → Outgoing transaction is sent → The remaining charges are returned to the battery (excess) + W5: The same, but everything in one transaction block + "Not enough funds" bottomsheet + Appears if there is not enough TON on the balance for the action and the battery is not charged + If battery payment is possible for the action, there is a "Recharge battery" button → Opens the battery screen with purchase options + There is always a "Buy TON" button → Leads to the TON purchase screen + Top up battery with crypto + It is possible to top up the battery by sending USDT | NOT | DOGS | JUSDT | CATI | HMSTR | tsTON | TON to the battery.ton address + It is possible to charge the battery of another wallet if the EQ/UQ address is indicated in the comment + Or through the interface on the battery screen + Crypto sorting: USDT is always first, TON is last, the rest as the backend gives + Shows the option to charge with USDT and TON always + Shows the option to charge with the rest of crypto only if it is on the balance + There are tariff packages with fixed amounts (small, medium, large) + Correct characteristics (i) for each tariff + It is possible to enter an arbitrary amount + During the first top-up the reserve amount is taken into account + When topping up "on credit" there is a check for the minimum top-up amount + When charging the battery with tokens (not TON) we give credit for the transaction. But we do not give it when the battery has a negative discharge + It is possible to charge the battery of another wallet - "Battery as a gift" + If charge to a TON network address EQ/UQ/dns + It is possible to view all purchases via the Charge History button + It is possible to make a refund of a crypto purchase within 14 days + When the battery is discharged to 0, the wallet itself switches to sending at the expense of the balance in TON + Applying a referral promo code + It is possible to enter/paste a promo code into the "Promo code" input + It is possible to follow a deeplink / QR with a promo code and it will be set + If the promo is valid -> a green checkmark icon appears next to it + A valid promo code is preserved when re-entering the screen / the app + If the promo is invalid -> error toast + the field is highlighted red -> the promo is not preserved when re-entering the screen + We bind the promo to the purchase + If charging with crypto, the promo is passed in the transaction. There is now a binary payload there, not a comment + If IAP purchase, there is an additional promo field in the post method + +Gasless flow + It is possible to send tokens USDT | NOT | DOGS | BOLT | TON etc. and pay the fee with them as well + If the battery is charged, it first suggests paying with it + If the battery is NOT charged + And if there is TON on the balance, it is possible to choose to send for TON or gasless + And if there is no TON on the balance, it immediately suggests sending for gasless + And if there is no TON on the balance AND not enough tokens for gasless, it shows the Not enough funds bottomsheet + Gasless works correctly when sending the entire balance (MAX) as well + + Stories + We can open stories from the backend + At app start + By deeplink https://app.tonkeeper.com/story/{{story_id}} + + Scanning QR code + It is possible to scan a QR code with an address or deeplink + with the device camera + from the main screen + from the sending flow + if there are no permissions - a stub with a request to give permissions on the device is displayed + On the scanner screen scanning QR from the gallery works, only screenshots are recognized (not photos) + +Deeplinks + https://mois-ilya.github.io/ton-wallet-deeplinks/#/tests + +Design + It is possible to choose a theme: Dark-blue, Dark, Light, System + Dark-blue by default + System dark = Dark / System light = Light + It is possible to choose an icon: Default, Accent, Dark, Light + Default icon by default + It is possible to enable the toggle for applying the Standard font size -> ignores the device font settings + +Widget + TODO diff --git a/.codex/skills/tk-impact-analisys/test-collection-template.txt b/.codex/skills/tk-impact-analisys/test-collection-template.txt new file mode 100644 index 000000000..0e9678ceb --- /dev/null +++ b/.codex/skills/tk-impact-analisys/test-collection-template.txt @@ -0,0 +1,3 @@ +TK impact suite + Impact block + Concrete test item [regress: TBD] diff --git a/.codex/skills/tondocs/SKILL.md b/.codex/skills/tondocs/SKILL.md new file mode 100644 index 000000000..8dcfa05fa --- /dev/null +++ b/.codex/skills/tondocs/SKILL.md @@ -0,0 +1,36 @@ +--- +name: tondocs +description: toolkit to sync and search TON documentation from git@github.com:ton-org/docs.git; use when answering TON-related questions or referencing TON docs +--- + +# Search TON documentation + +Use this skill to clone/update TON docs into `.context/docs.ton.org`, then search `.mdx` files for answers. + +## Prereqs +- Ensure `git` and `rg` are installed. +- Optional: set `TON_DOCS_REPO_URL` in the repo-root `.env` to override the default repo URL. +- Confirm setup: `python3 .codex/skills/tondocs/scripts/tondocs.py ensure-setup` returns zero exit code. + +## Usage +Clone docs (first time) or update: +```bash +python3 .codex/skills/tondocs/scripts/tondocs.py sync +python3 .codex/skills/tondocs/scripts/tondocs.py sync --update +``` + +List .mdx files: +```bash +python3 .codex/skills/tondocs/scripts/tondocs.py list-files +``` + +Search docs: +```bash +python3 .codex/skills/tondocs/scripts/tondocs.py search "wallet address" +python3 .codex/skills/tondocs/scripts/tondocs.py search "Message" --case-sensitive +``` + +## Output +- Docs checkout: `.context/docs.ton.org` (relative to repo root) +- Search output: raw `rg` matches with file and line numbers + diff --git a/.codex/skills/tondocs/scripts/tondocs.py b/.codex/skills/tondocs/scripts/tondocs.py new file mode 100644 index 000000000..fd8ba3215 --- /dev/null +++ b/.codex/skills/tondocs/scripts/tondocs.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +import argparse +import os +import shutil +import subprocess +import sys + +DEFAULT_REPO_URL = "git@github.com:ton-org/docs.git" +DEFAULT_DOCS_DIRNAME = "docs.ton.org" + +def _repo_root(): + current = os.path.abspath(os.path.dirname(__file__)) + while True: + if os.path.isdir(os.path.join(current, ".git")): + return current + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + +def load_env_file(path=None): + if path is None: + root = _repo_root() + path = os.path.join(root, ".env") if root else ".env" + if not os.path.isfile(path): + return + with open(path, "r", encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + + +def _docs_dir(): + root = _repo_root() + base = root if root else os.getcwd() + return os.path.join(base, ".context", DEFAULT_DOCS_DIRNAME) + + +def _repo_url(): + return os.environ.get("TON_DOCS_REPO_URL", DEFAULT_REPO_URL) + + +def _run_cmd(cmd, cwd=None, allowed_codes=(0,)): + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode not in allowed_codes: + raise RuntimeError(result.stderr.strip() or "Command failed") + return result.stdout + + +def ensure_setup(): + errors = [] + if not shutil.which("python3") and not shutil.which("python"): + errors.append("python3 is required") + if not shutil.which("git"): + errors.append("git is required") + if not shutil.which("rg"): + errors.append("rg is required") + if errors: + for error in errors: + print(f"Setup error: {error}") + sys.exit(1) + print("Setup OK.") + + +def sync_docs(update=False): + docs_dir = _docs_dir() + if not os.path.isdir(docs_dir): + os.makedirs(os.path.dirname(docs_dir), exist_ok=True) + _run_cmd(["git", "clone", _repo_url(), docs_dir]) + print(docs_dir) + return + if update: + _run_cmd(["git", "-C", docs_dir, "pull", "--ff-only"]) + print(docs_dir) + + +def list_files(): + docs_dir = _docs_dir() + if not os.path.isdir(docs_dir): + raise RuntimeError("Docs repo not found; run sync first") + output = _run_cmd(["rg", "--files", "-g", "*.mdx"], cwd=docs_dir) + print(output.rstrip("\n")) + + +def search_docs(query, case_sensitive=False): + docs_dir = _docs_dir() + if not os.path.isdir(docs_dir): + raise RuntimeError("Docs repo not found; run sync first") + cmd = ["rg", "--glob", "*.mdx"] + if not case_sensitive: + cmd.append("-i") + cmd.append(query) + output = _run_cmd(cmd, cwd=docs_dir, allowed_codes=(0, 1)) + print(output.rstrip("\n")) + + +def main(): + load_env_file() + parser = argparse.ArgumentParser(description="Sync and search TON docs") + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("ensure-setup", help="Verify dependencies") + + sync_parser = subparsers.add_parser("sync", help="Clone docs repo or update it") + sync_parser.add_argument("--update", action="store_true", help="Pull latest changes") + + subparsers.add_parser("list-files", help="List .mdx files in the docs repo") + + search_parser = subparsers.add_parser("search", help="Search .mdx files for a query") + search_parser.add_argument("query", help="Search query (rg syntax)") + search_parser.add_argument("--case-sensitive", action="store_true", help="Case-sensitive search") + + args = parser.parse_args() + + if args.command == "ensure-setup": + ensure_setup() + return + + if args.command == "sync": + sync_docs(update=args.update) + return + + if args.command == "list-files": + list_files() + return + + if args.command == "search": + search_docs(args.query, case_sensitive=args.case_sensitive) + return + + +if __name__ == "__main__": + main() diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..caa02b166 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/baselineprofile.yml b/.github/workflows/baselineprofile.yml index 70d5ab21e..d6b9c6917 100644 --- a/.github/workflows/baselineprofile.yml +++ b/.github/workflows/baselineprofile.yml @@ -6,7 +6,7 @@ on: jobs: baseline: name: generate-baselineprofile - runs-on: macOS-latest + runs-on: macos-latest-xlarge env: EMULATOR_API_LEVEL: '30' @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout to git repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: lfs: 'true' @@ -23,20 +23,20 @@ jobs: uses: actionsdesk/lfs-warning@v3.3 - name: Install JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: '22' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: Build benchmark id: gradle run: ./gradlew :apps:wallet:instance:main:generateReleaseBaselineProfile - name: AVD cache - uses: actions/cache@v4 + uses: actions/cache@v5 id: avd-cache with: path: | diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml new file mode 100644 index 000000000..8e6d8bbf7 --- /dev/null +++ b/.github/workflows/beta.yml @@ -0,0 +1,58 @@ +name: Android Beta Build + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + beta-build: + name: Build Beta APK + runs-on: macos-latest-xlarge + + steps: + - name: Checkout to git repository + uses: actions/checkout@v6 + + - name: Get VERSION_CODE from commit timestamp + run: echo "VERSION_CODE=$(git log -1 --format=%ct)" >> $GITHUB_ENV + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle Wrapper + uses: actions/cache@v5 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Accept Android SDK licenses + run: yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --licenses + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Decode Keystore + run: echo "${{ secrets.ANDROID_DIST_SIGNING_KEY }}" | base64 -d > ${{ github.workspace }}/release.keystore + + - name: Build Beta APK + run: | + ./gradlew :apps:wallet:instance:main:assembleDefaultRelease \ + --no-daemon \ + --no-build-cache \ + --no-configuration-cache \ + -Pandroid.injected.signing.store.file=${{ github.workspace }}/release.keystore \ + -Pandroid.injected.signing.store.password=${{ secrets.TONKEEPER_UPLOAD_STORE_PASSWORD }} \ + -Pandroid.injected.signing.key.alias=${{ secrets.TONKEEPER_UPLOAD_KEY_ALIAS }} \ + -Pandroid.injected.signing.key.password=${{ secrets.TONKEEPER_UPLOAD_KEY_PASSWORD }} + + - name: Upload Beta APK to artifacts + uses: actions/upload-artifact@v7 + with: + name: tonkeeper-beta-${{ env.VERSION_CODE }} + path: apps/wallet/instance/main/build/outputs/apk/default/release/*.apk \ No newline at end of file diff --git a/.github/workflows/build-apk-reusable.yml b/.github/workflows/build-apk-reusable.yml new file mode 100644 index 000000000..0be4daf6e --- /dev/null +++ b/.github/workflows/build-apk-reusable.yml @@ -0,0 +1,90 @@ +name: Build APK Reusable + +on: + workflow_call: + inputs: + gradle-task: + description: Gradle task used to build the APK + required: true + type: string + artifact-name-prefix: + description: Prefix for uploaded artifact name + required: true + type: string + apk-path-glob: + description: Glob path to built APK files + required: true + type: string + outputs: + artifact-name: + description: Uploaded artifact name + value: ${{ jobs.build-apk.outputs.artifact-name }} + version-code: + description: Build version code derived from commit timestamp + value: ${{ jobs.build-apk.outputs.version-code }} + +jobs: + build-apk: + name: Build APK + runs-on: macos-latest-xlarge + outputs: + artifact-name: ${{ steps.artifact_name.outputs.artifact_name }} + version-code: ${{ steps.version_code.outputs.version_code }} + + steps: + - name: Checkout to git repository + uses: actions/checkout@v6 + + - name: Get VERSION_CODE from commit timestamp + id: version_code + shell: bash + run: | + VERSION_CODE="$(git log -1 --format=%ct)" + echo "VERSION_CODE=${VERSION_CODE}" >> "$GITHUB_ENV" + echo "version_code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle Wrapper + uses: actions/cache@v5 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Accept Android SDK licenses + run: yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --licenses + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Decode keystore + shell: bash + run: echo "${{ secrets.TONKEEPER_DEBUG_STORE_FILE }}" | base64 --decode > "${{ github.workspace }}/internal.keystore" + + - name: Build APK + shell: bash + run: | + ./gradlew ${{ inputs.gradle-task }} \ + --no-daemon \ + --no-build-cache \ + --no-configuration-cache \ + -Pandroid.injected.signing.debug.file="${{ github.workspace }}/internal.keystore" \ + -Pandroid.injected.signing.debug.password="${{ secrets.TONKEEPER_DEBUG_STORE_PASSWORD }}" \ + -Pandroid.injected.signing.debug.key.alias="${{ secrets.TONKEEPER_DEBUG_KEY_ALIAS }}" \ + -Pandroid.injected.signing.debug.key.password="${{ secrets.TONKEEPER_DEBUG_KEY_PASSWORD }}" + + - name: Compose artifact name + id: artifact_name + shell: bash + run: echo "artifact_name=${{ inputs.artifact-name-prefix }}-${VERSION_CODE}" >> "$GITHUB_OUTPUT" + + - name: Upload built APK to artifacts + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.artifact_name.outputs.artifact_name }} + path: ${{ inputs.apk-path-glob }} + if-no-files-found: error diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index 9372310aa..000000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Android APK build CD - -on: - push: - branches: - - 'dev' - workflow_dispatch: - -jobs: - android-build: - name: android-build - runs-on: macos-latest - - steps: - - name: Checkout to git repository - uses: actions/checkout@v4 - - - name: Set up ruby env - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2 - bundler-cache: true - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: 20.0.2+9 - - - name: Accept Android SDK licenses - run: yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --licenses - - - name: Decode signing certificate into a file - env: - CERTIFICATE_BASE64: ${{ secrets.ANDROID_DIST_SIGNING_KEY }} - run: | - echo $CERTIFICATE_BASE64 | base64 --decode > google-release.keystore - - - name: Build android beta - run: bundle exec fastlane android beta - env: - KEYSTORE_FILE: ${{ github.workspace }}/google-release.keystore - KEYSTORE_PASSWORD: ${{ secrets.TONKEEPER_UPLOAD_STORE_PASSWORD }} - KEY_ALIAS: ${{ secrets.TONKEEPER_UPLOAD_KEY_ALIAS}} - KEY_PASSWORD: ${{ secrets.TONKEEPER_UPLOAD_KEY_PASSWORD }} - ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.ANDROID_PUBLISHER_CREDENTIALS }} - - - name: Upload android aab to artifacts - uses: actions/upload-artifact@v4 - with: - name: Tonkeeper aab ${{ env.VERSION_CODE }} - path: | - ${{ env.AAB_OUTPUT_PATH }} - - - name: Upload android apk to artifacts - uses: actions/upload-artifact@v4 - with: - name: Tonkeeper apk ${{ env.VERSION_CODE }} - path: | - ${{ env.APK_OUTPUT_PATH }} \ No newline at end of file diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000..b36a9a26e --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,79 @@ +name: Claude PR Review + +on: + pull_request: + types: [ opened, reopened ] + +permissions: + pull-requests: write + contents: read + id-token: write + +jobs: + overview: + if: contains(github.event.pull_request.body, '/claude-overview') || contains(github.event.pull_request.body, '@claude') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Main changes summary + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + settings: '{"permissions":{"allow":["Bash(gh pr comment:*)","Bash(gh pr diff:*)","Bash(gh pr view:*)"]}}' + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + You are a senior software engineer reviewing a pull request. + + Provide a concise summary of the main changes. + Group changes by area/module when possible. + Use bullet points. Be specific but brief. + Do NOT mention security concerns. + + If commit messages or the diff contain task numbers (e.g. TK-123, TK-456), + list them at the top of your summary under a "Related Tasks" heading. + + After composing your summary, post it as a single PR comment using: + gh pr comment --repo ${{ github.repository }} --body "" + claude_args: | + --max-turns 8 + --model claude-sonnet-4-5-20250929 + --allowedTools "Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" + + review: + if: contains(github.event.pull_request.body, '/claude-review') || contains(github.event.pull_request.body, '@claude') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + settings: '{"permissions":{"allow":["Bash(gh pr comment:*)","Bash(gh pr diff:*)","Bash(gh pr view:*)"]}}' + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + You are a senior software engineer performing a thorough code review on an Android (Kotlin) project. + + Provide actionable feedback. Focus on: + - Bugs and logic errors + - Performance issues + - Security issues + - Code readability and maintainability + - Kotlin/Android best practices + - Error handling gaps + - Potential race conditions or threading issues + + For each issue found, reference the file name and provide a clear explanation. + Do NOT comment on things that are correct or well-written — only comment on actual problems. + If the code looks good and has no issues, post a single brief PR comment saying so. + Do not invent problems. Be constructive and concise. + + Use inline comments for specific issues found in the code. + claude_args: | + --max-turns 25 + --model claude-opus-4-6 + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 000000000..3147a86d9 --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,24 @@ +name: Android Debug Build + +on: + push: + branches: + - 'dev' + - 'release/**' + pull_request: + types: [ opened, synchronize ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + debug-build: + name: Build Debug APK + uses: ./.github/workflows/build-apk-reusable.yml + with: + gradle-task: :apps:wallet:instance:main:assembleDefaultDebug + artifact-name-prefix: tonkeeper-debug + apk-path-glob: apps/wallet/instance/main/build/outputs/apk/default/debug/*.apk + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/maestro-tests.yml b/.github/workflows/maestro-tests.yml new file mode 100644 index 000000000..a3af36b38 --- /dev/null +++ b/.github/workflows/maestro-tests.yml @@ -0,0 +1,183 @@ +name: Maestro Tests + +on: + workflow_dispatch: + workflow_call: + +jobs: + build-apk: + name: Build Debug APK For Maestro + uses: ./.github/workflows/build-apk-reusable.yml + with: + gradle-task: :apps:wallet:instance:main:assembleDefaultDebug + artifact-name-prefix: tonkeeper-nightly-debug + apk-path-glob: apps/wallet/instance/main/build/outputs/apk/default/debug/*.apk + secrets: inherit + + discover-flow-groups: + name: Resolve Maestro flow shards from config + runs-on: ubuntu-latest + outputs: + flow_groups: ${{ steps.set.outputs.flow_groups }} + steps: + - uses: actions/checkout@v6 + + - id: set + run: | + set -euo pipefail + cfg="maestro_ui_tests/config.yaml" + if [[ ! -f "$cfg" ]]; then + echo "::error::maestro_ui_tests/config.yaml not found" + exit 1 + fi + # Lines like: - flows/browser_screen/* or - "flows/testnet/*" + mapfile -t groups < <( + sed -n 's/^[[:space:]]*-[[:space:]]*["'\'']*flows\/\([^/"*[:space:]]*\)\/.*/\1/p' "$cfg" | sort -u + ) + if [[ ${#groups[@]} -eq 0 ]]; then + echo "::error::No flow shard folders under 'flows:' in maestro_ui_tests/config.yaml (expected ' - flows//*')" + exit 1 + fi + for g in "${groups[@]}"; do + if [[ ! -d "maestro_ui_tests/flows/${g}" ]]; then + echo "::error::Flow folder from config missing on disk: maestro_ui_tests/flows/${g}" + exit 1 + fi + done + json="$(printf '%s\n' "${groups[@]}" | jq -R . | jq -s -c .)" + echo "flow_groups=$json" >> "$GITHUB_OUTPUT" + + maestro-tests: + name: Maestro — ${{ matrix.flow_group }} + needs: [build-apk, discover-flow-groups] + runs-on: linux-x64-16 + strategy: + fail-fast: false + max-parallel: 2 + matrix: + flow_group: ${{ fromJson(needs.discover-flow-groups.outputs.flow_groups) }} + env: + MAESTRO_AUTH: ${{ secrets.MAESTRO_AUTH }} + MAESTRO_MNEMONIC_PHRASE: ${{ secrets.MAESTRO_MNEMONIC_PHRASE }} + MAESTRO_TESTNET_MNEM: ${{ secrets.MAESTRO_TESTNET_MNEM }} + MAESTRO_RECIEVE_WALLET: ${{ secrets.MAESTRO_RECIEVE_WALLET }} + MAESTRO_WALLET_WITH_MONEY: ${{ secrets.MAESTRO_WALLET_WITH_MONEY }} + MAESTRO_WALLET_WITH_MONEY_ADDR: ${{ secrets.MAESTRO_WALLET_WITH_MONEY_ADDR }} + + steps: + - name: Checkout to git repository + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Download built APK artifact + uses: actions/download-artifact@v7 + with: + name: ${{ needs.build-apk.outputs.artifact-name }} + path: dist/apk + + - name: Resolve debug APK path + id: resolve_apk + shell: bash + run: | + set -euo pipefail + # upload-artifact preserves path segments; APK is under dist/apk/apps/... not dist/apk/*.apk + mapfile -t apks < <(find dist/apk -type f -name '*.apk' 2>/dev/null | sort) + + if [ ${#apks[@]} -eq 0 ]; then + echo "No debug APK found under dist/apk (recursive):" + find dist/apk -type f 2>/dev/null || true + exit 1 + fi + + if [ ${#apks[@]} -gt 1 ]; then + echo "Multiple debug APKs found, expected exactly one:" + printf ' - %s\n' "${apks[@]}" + exit 1 + fi + + echo "app_file=${apks[0]}" >> "$GITHUB_OUTPUT" + + - name: Validate Maestro secrets + shell: bash + run: | + for secret_name in \ + MAESTRO_AUTH \ + MAESTRO_MNEMONIC_PHRASE \ + MAESTRO_RECIEVE_WALLET \ + MAESTRO_WALLET_WITH_MONEY; do + if [ -z "${!secret_name}" ]; then + echo "::error::Missing secret: ${secret_name}" + exit 1 + fi + done + + - name: Install Maestro CLI + shell: bash + run: | + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run Maestro (${{ matrix.flow_group }}) — emulator (1/2) + id: maestro_shard_attempt1 + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2.37.0 + with: + api-level: 34 + arch: x86_64 + cores: 4 + ram-size: 8192M + heap-size: 1024M + disk-size: 8192M + target: google_apis + profile: pixel_3 + emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + disable-animations: true + script: APK_PATH="${{ steps.resolve_apk.outputs.app_file }}" MAESTRO_FLOW_GROUP="${{ matrix.flow_group }}" bash "$GITHUB_WORKSPACE/tools/ci/run-maestro-on-emulator.sh" + + - name: Run Maestro (${{ matrix.flow_group }}) — emulator (2/2, retry) + id: maestro_shard_attempt2 + if: steps.maestro_shard_attempt1.outcome == 'failure' + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2.37.0 + with: + api-level: 34 + arch: x86_64 + cores: 4 + ram-size: 8192M + heap-size: 1024M + disk-size: 8192M + target: google_apis + profile: pixel_3 + emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + disable-animations: true + script: APK_PATH="${{ steps.resolve_apk.outputs.app_file }}" MAESTRO_FLOW_GROUP="${{ matrix.flow_group }}" bash "$GITHUB_WORKSPACE/tools/ci/run-maestro-on-emulator.sh" + + - name: Maestro shard outcome (fail if both attempts failed) + if: always() + run: | + a1="${{ steps.maestro_shard_attempt1.outcome }}" + a2="${{ steps.maestro_shard_attempt2.outcome }}" + if [ "$a1" = "success" ]; then exit 0; fi + if [ "$a1" = "failure" ] && [ "$a2" = "success" ]; then exit 0; fi + echo "::error::Maestro shard ${{ matrix.flow_group }} failed (1st: ${a1}, 2nd: ${a2})" + exit 1 + + - name: Upload Maestro artifacts + if: always() + uses: actions/upload-artifact@v7 + with: + name: maestro-${{ matrix.flow_group }}-${{ needs.build-apk.outputs.version-code }} + path: build/maestro-results + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/mirror-public-build.yml b/.github/workflows/mirror-public-build.yml new file mode 100644 index 000000000..9ca864f0b --- /dev/null +++ b/.github/workflows/mirror-public-build.yml @@ -0,0 +1,82 @@ +name: Mirror APK release build + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build: + name: Build & Release APK + runs-on: macos-latest-xlarge + + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Parse tag into VERSION_NAME and VERSION_CODE + run: | + TAG="${{ github.ref_name }}" + # Tag format: v{version_name}_{version_code} + VERSION_NAME="${TAG#v}" + VERSION_NAME="${VERSION_NAME%_*}" + VERSION_CODE="${TAG##*_}" + echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV + echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV + echo "Parsed tag '$TAG' -> VERSION_NAME=$VERSION_NAME, VERSION_CODE=$VERSION_CODE" + + - name: Setup Java + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle Wrapper + uses: actions/cache@v5 + with: + path: ~/.gradle/wrapper + key: gradle-wrapper-${{ hashFiles('gradle/wrapper/gradle-wrapper.properties') }} + + - name: Accept Android SDK licenses + run: yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --licenses + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Decode Keystore + run: echo "${{ secrets.TONKEEPER_UPLOAD_STORE_FILE }}" | base64 -d > ${{ github.workspace }}/release.keystore + + - name: Build Site APK + run: | + ./gradlew :apps:wallet:instance:main:assembleSiteRelease \ + --no-daemon \ + --no-build-cache \ + --no-configuration-cache \ + -Pandroid.injected.signing.store.file=${{ github.workspace }}/release.keystore \ + -Pandroid.injected.signing.store.password=${{ secrets.TONKEEPER_UPLOAD_STORE_PASSWORD }} \ + -Pandroid.injected.signing.key.alias=${{ secrets.TONKEEPER_UPLOAD_KEY_ALIAS }} \ + -Pandroid.injected.signing.key.password=${{ secrets.TONKEEPER_UPLOAD_KEY_PASSWORD }} + + - name: Prepare APK for release + run: | + mkdir -p release + APK=$(find apps/wallet/instance/main/build/outputs/apk/site/release -name "*.apk" | head -1) + cp "$APK" release/Tonkeeper.apk + + - name: Upload APK artifact + uses: actions/upload-artifact@v7 + with: + name: Tonkeeper-apk-${{ github.ref_name }} + path: release/Tonkeeper.apk + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ env.VERSION_NAME }} + generate_release_notes: true + files: release/Tonkeeper.apk diff --git a/.github/workflows/mirror-public-push.yml b/.github/workflows/mirror-public-push.yml new file mode 100644 index 000000000..6cc788e90 --- /dev/null +++ b/.github/workflows/mirror-public-push.yml @@ -0,0 +1,58 @@ +name: Mirror GitHub release tag + +on: + workflow_dispatch: + +jobs: + push: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Verify running on a tag + run: | + if [[ "${{ github.ref }}" != refs/tags/* ]]; then + echo "::error::This workflow must be run on a tag, not a branch. Select a tag in 'Use workflow from'." + exit 1 + fi + + - name: Checkout private repo + uses: actions/checkout@v6 + + - name: Configure deploy key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PUBLIC_REPO_DEPLOY_KEY }} + + - name: Remove HTTPS override from actions/checkout + run: | + git config --global --unset-all url."https://github.com/".insteadOf || true + git config --global --unset-all http.https://github.com/.extraheader || true + + - name: Push squashed commit to public repo + env: + TAG: ${{ github.ref_name }} + run: | + git config --global user.name "mirror-bot" + git config --global user.email "mirror-bot@example.com" + + git remote add public git@github.com:tonkeeper/android.git + git fetch public main || true + + # Get the tree (snapshot) of the current tag + TREE=$(git log -1 --format='%T' HEAD) + + # Create a squashed commit on top of public's main history + if git rev-parse public/main >/dev/null 2>&1; then + COMMIT=$(git commit-tree "$TREE" -p public/main -m "Release $TAG") + else + # First push — no history yet + COMMIT=$(git commit-tree "$TREE" -m "Release $TAG") + fi + + # Push the commit to release branch and main + git push --force public "$COMMIT":refs/heads/release/"$TAG" + + # Tag the commit and push + git tag -f "$TAG" "$COMMIT" + git push --force public "refs/tags/$TAG" diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml new file mode 100644 index 000000000..c45694206 --- /dev/null +++ b/.github/workflows/nightly.yaml @@ -0,0 +1,68 @@ +name: Android Nightly Build + +on: + workflow_dispatch: + schedule: + - cron: '0 1 * * 2-6' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + beta-build: + name: Build Beta APK + if: github.repository != 'tonkeeper/android' + uses: ./.github/workflows/build-apk-reusable.yml + with: + gradle-task: :apps:wallet:instance:main:assembleDefaultBeta + artifact-name-prefix: tonkeeper-nightly-beta + apk-path-glob: apps/wallet/instance/main/build/outputs/apk/default/beta/*.apk + secrets: inherit + + firebase-distribution: + name: Firebase App Distribution + if: github.repository != 'tonkeeper/android' + needs: beta-build + runs-on: ubuntu-latest + steps: + - name: Download built APK artifact + uses: actions/download-artifact@v7 + with: + name: ${{ needs.beta-build.outputs.artifact-name }} + path: dist/apk + + - name: Resolve beta APK path + id: resolve_apk + shell: bash + run: | + set -euo pipefail + mapfile -t apks < <(find dist/apk -type f -name '*.apk' 2>/dev/null | sort) + + if [ ${#apks[@]} -eq 0 ]; then + echo "No beta APK found under dist/apk (recursive):" + find dist/apk -type f 2>/dev/null || true + exit 1 + fi + + if [ ${#apks[@]} -gt 1 ]; then + echo "Multiple beta APKs found, expected exactly one:" + printf ' - %s\n' "${apks[@]}" + exit 1 + fi + + echo "app_file=${apks[0]}" >> "$GITHUB_OUTPUT" + + - name: Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@bd494989dd4bec0343f78adee87fe66e48279ad6 # v1.7.1 + with: + appId: ${{secrets.FIREBASE_BETA_APP_ID}} + serviceCredentialsFileContent: ${{ secrets.FIREBASE_CREDENTIAL_FILE_CONTENT }} + file: ${{ steps.resolve_apk.outputs.app_file }} + groups: internal + + maestro-tests: + name: Maestro Tests + if: github.repository != 'tonkeeper/android' + uses: ./.github/workflows/maestro-tests.yml + secrets: inherit diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1747e9ae6..b9e0cd493 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,29 +1,81 @@ -name: Publish tagged commit to public repo +name: Android Publish +run-name: Android Publish ${{ inputs.version_name || github.event.release.tag_name }} on: - push: - tags: - - '*' + workflow_dispatch: + inputs: + version_name: + description: 'Version name (e.g. 1.2.3). Required for manual runs.' + required: true + type: string jobs: - mirror: - runs-on: ubuntu-latest + android-build: + name: android-build + runs-on: macos-latest-xlarge permissions: - contents: read + contents: write + steps: - - name: Checkout private repo at tag - uses: actions/checkout@v4 + - name: Checkout to git repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set VERSION_NAME + run: echo "VERSION_NAME=${{ inputs.version_name || github.event.release.tag_name }}" >> $GITHUB_ENV + + - name: Set up ruby env + uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0 with: - fetch-depth: 0 + ruby-version: 3.2 + bundler-cache: true - - name: Push tag to public repo + - name: Setup Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Accept Android SDK licenses + run: yes | $ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager --licenses + + - name: Decode signing certificate into a file env: - TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }} - TAG: ${{ github.ref_name }} + CERTIFICATE_BASE64: ${{ secrets.ANDROID_DIST_SIGNING_KEY }} run: | - git config --global user.name "mirror-bot" - git config --global user.email "mirror-bot@example.com" + echo $CERTIFICATE_BASE64 | base64 --decode > google-release.keystore - git remote add public https://$TOKEN@github.com/ORG/PUBLIC_REPO.git + - name: Build android beta + run: bundle exec fastlane android beta + env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.caching=false -Dorg.gradle.configuration-cache=false" + KEYSTORE_FILE: ${{ github.workspace }}/google-release.keystore + KEYSTORE_PASSWORD: ${{ secrets.TONKEEPER_UPLOAD_STORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.TONKEEPER_UPLOAD_KEY_ALIAS}} + KEY_PASSWORD: ${{ secrets.TONKEEPER_UPLOAD_KEY_PASSWORD }} + ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.ANDROID_PUBLISHER_CREDENTIALS }} - git push --force --tags public $TAG \ No newline at end of file + - name: Add build title to summary + run: echo "### ${{ env.VERSION_NAME }}_${{ env.VERSION_CODE }}" >> $GITHUB_STEP_SUMMARY + + - name: Create and push git tag + run: | + TAG="v${VERSION_NAME}_${VERSION_CODE}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$TAG" + git push origin "$TAG" + + - name: Upload android aab to artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Tonkeeper aab ${{ env.VERSION_CODE }} + path: | + ${{ env.AAB_OUTPUT_PATH }} + + - name: Upload android apk to artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Tonkeeper apk ${{ env.VERSION_CODE }} + path: | + ${{ env.APK_OUTPUT_PATH }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 193e23736..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,82 +0,0 @@ -name: Android APK release CD - -on: - push: - tags: - - 'v*' - -jobs: - build: - name: apk-release - runs-on: macos-latest - - steps: - - uses: actions/checkout@v4 - with: { fetch-depth: 0 } - - - name: Set up ruby env - uses: ruby/setup-ruby@v1 - with: - ruby-version: 3.2 - bundler-cache: true - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: 20.0.2+9 - - - name: Decode signing certificate into a file - env: - CERTIFICATE_BASE64: ${{ secrets.ANDROID_DIST_SIGNING_KEY }} - run: | - echo $CERTIFICATE_BASE64 | base64 --decode > google-release.keystore - - - name: Build android beta - run: bundle exec fastlane android beta - env: - KEYSTORE_FILE: ${{ github.workspace }}/google-release.keystore - KEYSTORE_PASSWORD: ${{ secrets.TONKEEPER_UPLOAD_STORE_PASSWORD }} - KEY_ALIAS: ${{ secrets.TONKEEPER_UPLOAD_KEY_ALIAS}} - KEY_PASSWORD: ${{ secrets.TONKEEPER_UPLOAD_KEY_PASSWORD }} - ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.ANDROID_PUBLISHER_CREDENTIALS }} - - - name: Upload android aab to artifacts - uses: actions/upload-artifact@v4 - with: - name: Tonkeeper aab ${{ env.VERSION_CODE }} - path: | - ${{ env.AAB_OUTPUT_PATH }} - - - name: Upload android apk to artifacts - uses: actions/upload-artifact@v4 - with: - name: Tonkeeper apk ${{ env.VERSION_CODE }} - path: | - ${{ env.APK_OUTPUT_PATH }} - - - name: Fix filename - run: | - mkdir -p release - cp "$APK_OUTPUT_PATH" release/Tonkeeper.apk - - - uses: actions/upload-artifact@v4 - with: - name: apk - path: release/Tonkeeper.apk - - publish: - needs: build - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - uses: actions/download-artifact@v4 - with: { name: apk } - - - uses: softprops/action-gh-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - files: Tonkeeper.apk \ No newline at end of file diff --git a/.github/workflows/uk.yml b/.github/workflows/uk.yml index afb0584b5..c03034688 100644 --- a/.github/workflows/uk.yml +++ b/.github/workflows/uk.yml @@ -6,11 +6,11 @@ on: jobs: android-build: name: android-build - runs-on: macos-latest + runs-on: macos-latest-xlarge steps: - name: Checkout to git repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up ruby env uses: ruby/setup-ruby@v1 @@ -19,7 +19,7 @@ jobs: bundler-cache: true - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: 20.0.2+9 @@ -40,14 +40,14 @@ jobs: ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.ANDROID_PUBLISHER_CREDENTIALS }} - name: Upload android aab to artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Tonkeeper UK aab ${{ env.VERSION_CODE }} path: | ${{ env.AAB_OUTPUT_PATH }} - name: Upload android apk to artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Tonkeeper UK apk ${{ env.VERSION_CODE }} path: | diff --git a/.gitignore b/.gitignore index 0e69164b8..175520f93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ *.iml .gradle local.properties -.idea +.idea/ .DS_Store captures .externalNativeBuild @@ -9,4 +9,10 @@ captures *.apk .kotlin /build +**/build/ +.build-cache +tools/build/ +mavenLocal +## Codex generated skill artifacts +.codex/skills/tk-impact-analisys/reports/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 8f00030d5..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# GitHub Copilot persisted chat sessions -/copilot/chatSessions diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index 72af3841f..000000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -TON Apps \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index 312bf2eab..000000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml deleted file mode 100644 index e922b6a83..000000000 --- a/.idea/deploymentTargetDropDown.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 8cfc672e9..000000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index c224ad564..000000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 99b6bd53c..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f4..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 000000000..36ffaac12 --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,61 @@ +# Agent Guide + +This file helps AI agents navigate the project based on the task at hand. + +## Task-Based Navigation + +### Understanding Project Structure + +- [README.MD](README.MD) — project overview, module map, tech stack, build instructions +- [settings.gradle.kts](settings.gradle.kts) — all modules and their include paths +- [gradle/libs.versions.toml](gradle/libs.versions.toml) — centralized dependency versions +- [kmp/mvi/README.MD](kmp/mvi/README.MD) — MVI architecture guide with examples + +### Build Configuration + +- [build.gradle.kts](build.gradle.kts) — root build config, signing, git hooks +- [gradle.properties](gradle.properties) — JVM args, Kotlin settings, caching +- [apps/wallet/instance/main/build.gradle.kts](apps/wallet/instance/main/build.gradle.kts) — main + app build (flavors, signing, R8) +- [apps/wallet/instance/main/proguard-rules.pro](apps/wallet/instance/main/proguard-rules.pro) — + ProGuard/R8 rules + +### Networking & API + +- [tonapi/](tonapi/) — TON API client (core, tonkeeper, battery, legacy) +- [lib/network/](lib/network/) — OkHttp/SSE networking utilities +- [apps/wallet/api/](apps/wallet/api/) — wallet API layer + +### Security & Crypto + +- [lib/security/](lib/security/) — encryption, vault, KeyStore +- [lib/blockchain/](lib/blockchain/) — TON blockchain operations + +### KMP Modules + +- [kmp/mvi/](kmp/mvi/) — MVI architecture framework +- [kmp/async/](kmp/async/) — coroutine scope management (Async.Io, Async.stateDispatcher) +- [kmp/ui/](kmp/ui/) — Compose Multiplatform UI components +- [kmp/core/](kmp/core/) — platform abstractions + +### Threading Model + +- [kmp/mvi/src/commonMain/kotlin/com/tonapps/mvi/AsyncViewModel.kt](kmp/mvi/src/commonMain/kotlin/com/tonapps/mvi/AsyncViewModel.kt) — + bgScope, stateScope, mainScope +- [kmp/async/src/commonMain/kotlin/com/tonapps/async/Async.kt](kmp/async/src/commonMain/kotlin/com/tonapps/async/Async.kt) — + dispatcher definitions + +### Code Quality & CI + +- [detekt/detekt.yml](detekt/detekt.yml) — Detekt static analysis rules +- [tools/hooks/](tools/hooks/) — Git pre-commit hooks +- [.github/workflows/](/.github/workflows/) — CI/CD workflows (debug, ci, release, baselineprofile) + +### Localization + +- [apps/wallet/localization/](apps/wallet/localization/) — i18n string resources + +### API Code Generation + +- [tools/scripts/generators/tonapi/](tools/scripts/generators/tonapi/) — TON API client generation +- [tools/scripts/generators/battery/](tools/scripts/generators/battery/) — Battery API generation diff --git a/README.md b/README.md index 41d247ebc..c04ed5103 100644 --- a/README.md +++ b/README.md @@ -1 +1,211 @@ -# tonkeeper +# Tonkeeper + +A production-grade Android wallet application for TON blockchain, built with Kotlin and Jetpack +Compose. + +## Project Overview + +Tonkeeper is a multi-app Android project consisting of: + +- **Tonkeeper Wallet** — main cryptocurrency wallet application +- **Tonkeeper Signer** — companion offline signer application +- Multiple shared libraries and Kotlin Multiplatform modules + +## Project Structure + +``` +repo_root/ +├── apps/ # Applications +│ ├── wallet/ # Main wallet app +│ │ ├── instance/ +│ │ │ ├── app/ # Core wallet (com.tonapps.tonkeeperx) +│ │ │ └── main/ # Main release (com.ton_keeper) +│ │ ├── data/ # Data layer (18 modules) +│ │ │ ├── core/ # Core data infrastructure +│ │ │ ├── account/ # Account management +│ │ │ ├── tokens/ # Token management +│ │ │ ├── settings/ # User settings +│ │ │ ├── rates/ # Exchange rates +│ │ │ ├── events/ # Transaction history +│ │ │ ├── collectibles/ # NFT support +│ │ │ ├── browser/ # DApp browser +│ │ │ ├── backup/ # Backup/restore +│ │ │ ├── passcode/ # Biometric/PIN +│ │ │ ├── staking/ # Staking operations +│ │ │ ├── purchase/ # In-app purchases +│ │ │ ├── battery/ # Battery/status +│ │ │ ├── dapps/ # DApp connections +│ │ │ ├── contacts/ # Address book +│ │ │ ├── swap/ # Token swap +│ │ │ ├── plugins/ # Plugin system +│ │ │ └── rn/ # React Native bridge +│ │ ├── features/ # Feature modules +│ │ ├── api/ # API layer +│ │ └── localization/ # i18n/localization +│ └── signer/ # Signer app (com.tonapps.signer) +│ +├── kmp/ # Kotlin Multiplatform modules +│ ├── mvi/ # MVI architecture framework +│ ├── async/ # Coroutine scope management +│ ├── ui/ # Compose Multiplatform UI +│ └── core/ # Platform abstractions +│ +├── lib/ # Shared libraries +│ ├── extensions/ # Kotlin extensions +│ ├── security/ # Crypto, vault, KeyStore +│ ├── network/ # OkHttp/SSE networking +│ ├── blockchain/ # TON blockchain operations +│ ├── sqlite/ # Database layer +│ ├── qr/ # QR code handling +│ ├── ledger/ # Ledger hardware wallet +│ ├── log/ # Logging +│ ├── icu/ # ICU internationalization +│ ├── emoji/ # Emoji handling +│ ├── base64/ # Base64 encoding +│ ├── bus/ # Event bus +│ └── ur/ # Uniform Resources +│ +├── ui/ # UI modules +│ ├── uikit/ +│ │ ├── core/ # BaseFragment, Navigation, components +│ │ ├── color/ # Color themes +│ │ ├── icon/ # Icon set +│ │ ├── list/ # List components +│ │ └── flag/ # Flag components +│ ├── blur/ # Blur effects +│ └── shimmer/ # Shimmer animations +│ +├── tonapi/ # TON API client library +│ ├── core/ # Core API client +│ ├── tonkeeper/ # Tonkeeper-specific API +│ ├── battery/ # Battery API +│ └── legacy/ # Legacy API +│ +├── buildLogic/ # Custom Gradle plugins +├── baselineprofile/ # Performance optimization profiles +├── detekt/ # Code quality configuration +├── fastlane/ # CI/CD automation +└── tools/ # Build tools and scripts + ├── hooks/ # Git pre-commit hooks + └── scripts/ # Code generators (tonapi, battery) +``` + +## Tech Stack + +| Category | Technologies | +|---------------|---------------------------------------------------------------| +| Language | Kotlin, KMP (Kotlin Multiplatform) | +| UI | Jetpack Compose, Compose Multiplatform, Material 3 | +| Architecture | MVI (custom KMP implementation) | +| DI | Koin | +| Async | Kotlin Coroutines, Flow | +| Networking | OkHttp (Cronet), SSE | +| Serialization | Kotlin Serialization | +| Database | SQLite (bundled) | +| Blockchain | ton-kotlin (tvm, crypto, tlb, contract), Web3j, Bouncy Castle | +| Firebase | Analytics, Crashlytics, Messaging, Performance, Remote Config | +| Image Loading | Coil | +| Code Quality | Detekt, R8/ProGuard, Baseline Profiles | +| CI/CD | GitHub Actions, Fastlane | + +## Build Configuration + +**Requirements:** JDK 21 (Temurin/Zulu), Android SDK (API 36), 6 GB RAM + +### Build Flavors + +| Flavor | Description | +|-----------|--------------------------| +| `default` | Standard build | +| `site` | Web distribution variant | + +### Build Types + +| Type | Minification | ABIs | +|-----------|--------------|-------------------------------------| +| `debug` | No | arm64-v8a | +| `beta` | Yes | arm64-v8a, x86_64 | +| `release` | Yes | arm64-v8a, armeabi-v7a, x86, x86_64 | + +### Building + +```bash +# Wallet debug +./gradlew :apps:wallet:instance:main:assembleDefaultDebug + +# Wallet release +./gradlew :apps:wallet:instance:main:assembleDefaultRelease + +# Signer +./gradlew :apps:signer:assembleDebug +``` + +### Signing Configuration + +The project supports flexible signing configuration for both debug and release builds: + +#### Release Builds + +Release builds use injected signing properties for CI/CD: + +```properties +android.injected.signing.store.file= +android.injected.signing.store.password= +android.injected.signing.key.alias= +android.injected.signing.key.password= +``` + +#### Debug Builds + +Debug builds support two signing methods: + +1. **Injected Properties** (for CI/CD): + +```properties +android.injected.signing.debug.file= +android.injected.signing.debug.password= +android.injected.signing.debug.key.alias= +android.injected.signing.debug.key.password= +``` + +2. **Default Debug Keystore** (local development): + - File: [debug.keystore](debug.keystore) (in project root) + - Store Password: `android` + - Key Alias: `androiddebugkey` + - Key Password: `android` + +The build system automatically falls back to the default debug keystore if no injected properties +are provided. + +## Architecture + +The project uses a custom **MVI (Model-View-Intent)** architecture built as a KMP module. +See [kmp/mvi/README.md](kmp/mvi/README.md) for detailed documentation. + +Key patterns: + +- **Unidirectional data flow:** Action → State → ViewState → Compose +- **Repository pattern:** Data access abstracted through 18 focused data modules +- **Koin DI:** ViewModels and repositories registered in module-level Koin modules +- **Thread safety:** Dedicated dispatchers for state mutations, computation, IO + +## CI/CD + +GitHub Actions workflows in `.github/workflows/`: + +| Workflow | Trigger | Purpose | +|-----------------------|----------------|--------------------------------| +| `debug.yml` | Push to `dev` | Dev builds | +| `ci.yml` | Tagged commits | Release builds | +| `baselineprofile.yml` | Manual | Performance profile generation | +| `publish.yml` | Manual | Mirror to public repository | + +## Key Configuration Files + +| File | Purpose | +|-----------------------------|---------------------------------------------| +| `settings.gradle.kts` | Module definitions | +| `build.gradle.kts` | Global plugins, signing, git hooks | +| `gradle.properties` | JVM args (-Xmx6g), caching, Kotlin settings | +| `gradle/libs.versions.toml` | Centralized dependency versions | +| `detekt/detekt.yml` | Static analysis rules | diff --git a/apps/signer/build.gradle.kts b/apps/signer/build.gradle.kts index 97604812e..6bba6a822 100644 --- a/apps/signer/build.gradle.kts +++ b/apps/signer/build.gradle.kts @@ -1,18 +1,14 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - id("com.android.application") - id("org.jetbrains.kotlin.android") + id("target.android.app") + alias(libs.plugins.kotlin.compose) } android { - namespace = Build.namespacePrefix("signer") - compileSdk = Build.compileSdkVersion - + namespace = "com.tonapps.signer" defaultConfig { - applicationId = Build.namespacePrefix("signer") + applicationId = "com.tonapps.signer" minSdk = 26 - targetSdk = Build.compileSdkVersion versionCode = 23 versionName = "0.2.3" } @@ -51,36 +47,36 @@ android { } dependencies { - implementation(libs.androidX.core) - implementation(libs.androidX.appCompat) - implementation(libs.androidX.activity) - implementation(libs.androidX.fragment) - implementation(libs.androidX.recyclerView) - implementation(libs.androidX.viewPager2) - implementation(libs.androidX.splashscreen) + implementation(libs.androidx.core) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity) + implementation(libs.androidx.fragment) + implementation(libs.androidx.recyclerView) + implementation(libs.androidx.viewPager2) + implementation(libs.androidx.splashscreen) implementation(libs.material) implementation(libs.flexbox) - implementation(libs.cameraX.base) - implementation(libs.cameraX.core) - implementation(libs.cameraX.lifecycle) - implementation(libs.cameraX.view) - implementation(libs.androidX.security) - implementation(libs.androidX.constraintlayout) - implementation(libs.androidX.lifecycleSavedState) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) + implementation(libs.camerax.base) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) + implementation(libs.androidx.security) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.lifecycleSavedState) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) - implementation(libs.kotlinX.coroutines.guava) + implementation(libs.kotlinx.coroutines.core) - implementation(project(ProjectModules.UIKit.core)) { + implementation(projects.ui.uikit.core) { exclude("com.airbnb.android", "lottie") exclude("com.facebook.fresco", "fresco") } - implementation(project(ProjectModules.Lib.qr)) - implementation(project(ProjectModules.Lib.security)) - implementation(project(ProjectModules.Lib.icu)) + implementation(projects.lib.qr) + implementation(projects.lib.security) + implementation(projects.lib.icu) implementation(libs.koin.core) } diff --git a/apps/signer/proguard-rules.pro b/apps/signer/proguard-rules.pro index 71a6747bd..2d84b92b6 100644 --- a/apps/signer/proguard-rules.pro +++ b/apps/signer/proguard-rules.pro @@ -12,4 +12,14 @@ -keep class com.fasterxml.jackson.databind.ext.** { *; } -dontwarn org.slf4j.** -dontwarn org.w3c.dom.** --dontwarn com.fasterxml.jackson.databind.ext.DOMSerializer \ No newline at end of file +-dontwarn com.fasterxml.jackson.databind.ext.DOMSerializer + +# Strip all Android logging for security and performance +-assumenosideeffects class android.util.Log { + public static boolean isLoggable(java.lang.String, int); + public static int v(...); + public static int i(...); + public static int w(...); + public static int d(...); + public static int e(...); +} \ No newline at end of file diff --git a/apps/signer/src/main/java/com/tonapps/signer/App.kt b/apps/signer/src/main/java/com/tonapps/signer/App.kt index 2fd862965..0b8e6901d 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/App.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/App.kt @@ -1,7 +1,6 @@ package com.tonapps.signer import android.app.Application -import android.content.Intent import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.camera.camera2.Camera2Config @@ -11,7 +10,7 @@ import com.tonapps.signer.screen.crash.CrashActivity import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin -class App: Application(), CameraXConfig.Provider { +class App : Application(), CameraXConfig.Provider { companion object { lateinit var instance: App @@ -25,7 +24,7 @@ class App: Application(), CameraXConfig.Provider { } AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) - startKoin{ + startKoin { androidContext(this@App) modules(koinModel) } @@ -37,4 +36,4 @@ class App: Application(), CameraXConfig.Provider { .setAvailableCamerasLimiter(CameraSelector.DEFAULT_BACK_CAMERA) .setMinimumLoggingLevel(Log.ERROR).build() } -} \ No newline at end of file +} diff --git a/apps/signer/src/main/java/com/tonapps/signer/core/source/BackupSource.kt b/apps/signer/src/main/java/com/tonapps/signer/core/source/BackupSource.kt index b3a42c620..9e29c253b 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/core/source/BackupSource.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/core/source/BackupSource.kt @@ -1,15 +1,13 @@ package com.tonapps.signer.core.source import android.content.Context -import android.util.Log import com.tonapps.signer.core.entities.KeyEntity import org.json.JSONArray import org.json.JSONObject internal class BackupSource( - private val context: Context, + context: Context, ) { - private val backupFile = context.getFileStreamPath("backup.json") init { diff --git a/apps/signer/src/main/java/com/tonapps/signer/core/source/SQLSource.kt b/apps/signer/src/main/java/com/tonapps/signer/core/source/SQLSource.kt index 082b414b2..3f78776cf 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/core/source/SQLSource.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/core/source/SQLSource.kt @@ -124,5 +124,4 @@ class SQLSource( backup.add(KeyEntity(id, name, publicKey)) id } - -} \ No newline at end of file +} diff --git a/apps/signer/src/main/java/com/tonapps/signer/deeplink/TKDeepLink.kt b/apps/signer/src/main/java/com/tonapps/signer/deeplink/TKDeepLink.kt index c31942b23..3160858a5 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/deeplink/TKDeepLink.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/deeplink/TKDeepLink.kt @@ -3,7 +3,7 @@ package com.tonapps.signer.deeplink import android.content.Context import android.content.Intent import android.net.Uri -import android.util.Log +import com.tonapps.log.L import com.tonapps.blockchain.ton.extensions.base64 import com.tonapps.blockchain.ton.extensions.hex import com.tonapps.security.hex diff --git a/apps/signer/src/main/java/com/tonapps/signer/deeplink/entities/SignRequestEntity.kt b/apps/signer/src/main/java/com/tonapps/signer/deeplink/entities/SignRequestEntity.kt index aaa2b415d..57b1ce05c 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/deeplink/entities/SignRequestEntity.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/deeplink/entities/SignRequestEntity.kt @@ -14,17 +14,6 @@ data class SignRequestEntity( val returnResult: ReturnResultEntity ) { - companion object { - - fun safe(uri: Uri, returnResult: ReturnResultEntity): SignRequestEntity? { - return try { - SignRequestEntity(uri, returnResult) - } catch (e: Throwable) { - null - } - } - } - private val tonNetwork: String = uri.getMultipleQuery("tn", "network") ?: "mainnet" val body: Cell = uri.getQueryParameter(Key.BODY)?.cellFromHex() ?: throw IllegalArgumentException("body is required") @@ -39,5 +28,14 @@ data class SignRequestEntity( val v: String = uri.getQueryParameter(Key.V) ?: "v4r2" val seqno: Int = uri.getQueryParameter(Key.SEQNO)?.toIntOrNull() ?: 1 + companion object { -} \ No newline at end of file + fun safe(uri: Uri, returnResult: ReturnResultEntity): SignRequestEntity? { + return try { + SignRequestEntity(uri, returnResult) + } catch (e: Throwable) { + null + } + } + } +} diff --git a/apps/signer/src/main/java/com/tonapps/signer/extensions/Context.kt b/apps/signer/src/main/java/com/tonapps/signer/extensions/Context.kt index b1e5a34dc..294a027b3 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/extensions/Context.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/extensions/Context.kt @@ -18,7 +18,7 @@ import uikit.navigation.Navigation.Companion.navigation fun Context.copyToClipboard(text: String, sensitive: Boolean = false) { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = ClipData.newPlainText("", text) - if (sensitive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (sensitive) { val extras = PersistableBundle() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) @@ -43,21 +43,9 @@ fun Context.authorizationRequiredError() { navigation?.toast(getString(R.string.authorization_required)) } -fun Context.securePrefs(name: String): SharedPreferences { - KeyHelper.createIfNotExists(name) - - return EncryptedSharedPreferences.create( - name, - name, - this, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) -} - fun Context.openAppSettings() { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.setData(Uri.fromParts("package", packageName, null)) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK startActivity(intent) -} \ No newline at end of file +} diff --git a/apps/signer/src/main/java/com/tonapps/signer/screen/create/child/CreateNameFragment.kt b/apps/signer/src/main/java/com/tonapps/signer/screen/create/child/CreateNameFragment.kt index d592e7e6d..f48f3a3a5 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/screen/create/child/CreateNameFragment.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/screen/create/child/CreateNameFragment.kt @@ -1,7 +1,7 @@ package com.tonapps.signer.screen.create.child import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import android.widget.Button import androidx.lifecycle.lifecycleScope diff --git a/apps/signer/src/main/java/com/tonapps/signer/screen/emulate/EmulateFragment.kt b/apps/signer/src/main/java/com/tonapps/signer/screen/emulate/EmulateFragment.kt index 76b2894ac..e2f559c51 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/screen/emulate/EmulateFragment.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/screen/emulate/EmulateFragment.kt @@ -1,7 +1,7 @@ package com.tonapps.signer.screen.emulate import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import android.widget.Button import com.tonapps.qr.ui.QRView diff --git a/apps/signer/src/main/java/com/tonapps/signer/screen/name/NameFragment.kt b/apps/signer/src/main/java/com/tonapps/signer/screen/name/NameFragment.kt index 24479395f..6f3a99d68 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/screen/name/NameFragment.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/screen/name/NameFragment.kt @@ -1,7 +1,7 @@ package com.tonapps.signer.screen.name import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import android.view.inputmethod.EditorInfo import android.widget.Button diff --git a/apps/signer/src/main/java/com/tonapps/signer/screen/root/RootActivity.kt b/apps/signer/src/main/java/com/tonapps/signer/screen/root/RootActivity.kt index ea978bd0f..f3b0ad7b7 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/screen/root/RootActivity.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/screen/root/RootActivity.kt @@ -4,7 +4,7 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import android.view.WindowManager import androidx.appcompat.widget.AppCompatImageView @@ -36,7 +36,6 @@ import org.ton.crypto.hex import uikit.dialog.alert.AlertDialog import uikit.extensions.collectFlow import uikit.extensions.dp -import uikit.extensions.findFragment import uikit.extensions.isFragmentExists import uikit.extensions.primaryFragment import uikit.extensions.round diff --git a/apps/signer/src/main/java/com/tonapps/signer/vault/SignerVault.kt b/apps/signer/src/main/java/com/tonapps/signer/vault/SignerVault.kt index 0061c6882..b089c9500 100644 --- a/apps/signer/src/main/java/com/tonapps/signer/vault/SignerVault.kt +++ b/apps/signer/src/main/java/com/tonapps/signer/vault/SignerVault.kt @@ -1,7 +1,7 @@ package com.tonapps.signer.vault import android.content.Context -import com.tonapps.signer.extensions.securePrefs +import com.tonapps.security.Security import org.ton.api.pk.PrivateKeyEd25519 import org.ton.mnemonic.Mnemonic import com.tonapps.security.clear @@ -15,7 +15,7 @@ import javax.crypto.SecretKey class SignerVault( context: Context, name: String, -): Vault(context.securePrefs(name)) { +): Vault(Security.pref(context, name, name)) { constructor(context: Context): this(context, "signer") diff --git a/apps/wallet/api/build.gradle.kts b/apps/wallet/api/build.gradle.kts index 1eadc13f7..f7186dc5d 100644 --- a/apps/wallet/api/build.gradle.kts +++ b/apps/wallet/api/build.gradle.kts @@ -1,38 +1,41 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") + id("target.android.library") id("com.google.devtools.ksp") id("kotlin-parcelize") kotlin("plugin.serialization") } android { - namespace = Build.namespacePrefix("wallet.api") - compileSdk = Build.compileSdkVersion - - defaultConfig { - minSdk = Build.minSdkVersion - consumerProguardFiles("consumer-rules.pro") + buildFeatures { + buildConfig = true } } dependencies { - implementation(libs.kotlinX.serialization.core) - implementation(libs.kotlinX.serialization.json) - implementation(libs.kotlinX.coroutines.guava) +// configurations.all { +// exclude(mapOf("group" to "org.chromium.net", "module" to "cronet-shared")) +// } +// implementation(libs.cronet.okhttp) +// implementation(libs.google.play.cronet) + + implementation(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) implementation(libs.koin.core) - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Lib.network)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.icu)) - implementation(libs.google.play.cronet) implementation(libs.okhttp) implementation(libs.okhttp.sse) + implementation(libs.koin.core) + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) - implementation(libs.androidX.room.runtime) - implementation(libs.androidX.room.ktx) - ksp(libs.androidX.room.compiler) + implementation(projects.tonapi.legacy) + implementation(projects.lib.network) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) + implementation(projects.lib.log) + implementation(projects.lib.features) + implementation(projects.apps.wallet.data.features) } diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/API.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/API.kt index e10e6dece..9d5953ef5 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/API.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/API.kt @@ -3,33 +3,48 @@ package com.tonapps.wallet.api import android.content.Context import android.net.Uri import androidx.collection.ArrayMap +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.errors.SendBlockchainException +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.contract.BaseWalletContract import com.tonapps.blockchain.ton.contract.WalletVersion import com.tonapps.blockchain.ton.extensions.EmptyPrivateKeyEd25519 import com.tonapps.blockchain.ton.extensions.base64 +import com.tonapps.blockchain.ton.extensions.cellFromHex import com.tonapps.blockchain.ton.extensions.hex import com.tonapps.blockchain.ton.extensions.isValidTonAddress import com.tonapps.blockchain.ton.extensions.toRawAddress import com.tonapps.extensions.map import com.tonapps.extensions.toUriOrNull import com.tonapps.icu.Coins +import com.tonapps.log.L import com.tonapps.network.SSEvent import com.tonapps.network.execute import com.tonapps.network.get +import com.tonapps.network.interceptor.LoggingInterceptor import com.tonapps.network.post import com.tonapps.network.postJSON import com.tonapps.network.requestBuilder import com.tonapps.network.sse +import com.tonapps.network.ssePost +import com.tonapps.wallet.api.Constants.SWAP_API +import com.tonapps.wallet.api.Constants.TRADING_API +import com.tonapps.wallet.api.configs.CountryConfig +import com.tonapps.wallet.api.core.ExchangeAPI +import com.tonapps.wallet.api.core.TradingAPI import com.tonapps.wallet.api.entity.AccountDetailsEntity import com.tonapps.wallet.api.entity.AccountEventEntity -import com.tonapps.wallet.api.entity.BalanceEntity import com.tonapps.wallet.api.entity.ChartEntity import com.tonapps.wallet.api.entity.ConfigEntity +import com.tonapps.wallet.api.entity.EmulateWithBatteryResult +import com.tonapps.wallet.api.entity.BannerEntity import com.tonapps.wallet.api.entity.EthenaEntity import com.tonapps.wallet.api.entity.OnRampArgsEntity import com.tonapps.wallet.api.entity.OnRampMerchantEntity -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.wallet.api.entity.toncenter.ToncenterSSERequest import com.tonapps.wallet.api.entity.value.Timestamp +import com.tonapps.wallet.api.extensions.toTokenEntity import com.tonapps.wallet.api.internal.ConfigRepository import com.tonapps.wallet.api.internal.InternalApi import com.tonapps.wallet.api.internal.SwapApi @@ -40,7 +55,10 @@ import io.batteryapi.models.Balance import io.batteryapi.models.Config import io.batteryapi.models.EstimateGaslessCostRequest import io.batteryapi.models.RechargeMethods +import io.infrastructure.ClientError import io.infrastructure.ClientException +import io.infrastructure.ServerError +import io.infrastructure.ServerException import io.tonapi.models.Account import io.tonapi.models.AccountAddress import io.tonapi.models.AccountEvent @@ -48,17 +66,23 @@ import io.tonapi.models.AccountEvents import io.tonapi.models.AccountStatus import io.tonapi.models.EmulateMessageToWalletRequest import io.tonapi.models.EmulateMessageToWalletRequestParamsInner +import io.tonapi.models.GetWalletsByPublicKeyBulkRequest import io.tonapi.models.MessageConsequences import io.tonapi.models.NftItem import io.tonapi.models.SendBlockchainMessageRequest import io.tonapi.models.TokenRates +import io.tonapi.models.WalletsByPublicKey import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient @@ -68,49 +92,113 @@ import okhttp3.Response import org.json.JSONArray import org.json.JSONObject import org.ton.api.pub.PublicKeyEd25519 +import org.ton.block.StateInit import org.ton.cell.Cell import org.ton.crypto.hex +import org.ton.tlb.asRef +import java.math.BigDecimal import java.util.Locale +@Suppress("LargeClass") class API( private val context: Context, - private val scope: CoroutineScope -) : CoreAPI(context) { + private val scope: CoroutineScope, + private val delegate: LoggingInterceptor.Delegate, +) : CoreAPI(context, delegate) { private val internalApi = InternalApi(context, defaultHttpClient, appVersionName) private val swapApi = SwapApi(defaultHttpClient) private val configRepository = ConfigRepository(context, scope, internalApi) - val config: ConfigEntity - get() = configRepository.configEntity - val configFlow: Flow get() = configRepository.stream + private val mainnetConfig: ConfigEntity + get() = getConfig(TonNetwork.MAINNET) + private val tonAPIHttpClient: OkHttpClient by lazy { - tonAPIHttpClient { config } + tonAPIHttpClient { getConfig(TonNetwork.MAINNET) } + } + + private val sseHttpClient: OkHttpClient by lazy { + sseHttpClient { getConfig(TonNetwork.MAINNET) } } private val bridgeUrl: String - get() = "${config.tonConnectBridgeHost}/bridge" + get() = "${mainnetConfig.tonConnectBridgeHost}/bridge" val country: String get() = internalApi.country - fun setCountry(deviceCountry: String, storeCountry: String?) = internalApi.setCountry(deviceCountry, storeCountry) + val exchange: ExchangeAPI by lazy { +// ExchangeAPI("https://dev-swap.tonkeeper.com", tonAPIHttpClient) + ExchangeAPI(SWAP_API, tonAPIHttpClient) + } + + val trading: TradingAPI by lazy { + TradingAPI(TRADING_API, tonAPIHttpClient) + } + + //TODO TK-371 + private val _providerState: StateFlow = configRepository.stream + .map { buildProvider() } + .stateIn(scope, SharingStarted.Eagerly, buildProvider()) + + private val _batteryProviderState: StateFlow = configRepository.stream + .map { buildBatteryProvider() } + .stateIn(scope, SharingStarted.Eagerly, buildBatteryProvider()) + + private val provider: Provider + get() = _providerState.value + private val batteryProvider: BatteryProvider + get() = _batteryProviderState.value + + val tron: TronApi by lazy { + TronApi(mainnetConfig, defaultHttpClient, batteryProvider.default.get(TonNetwork.MAINNET)) + } + + private fun buildProvider() = Provider( + mainnetHost = getConfig(TonNetwork.MAINNET).tonapiMainnetHost, + testnetHost = getConfig(TonNetwork.TESTNET).tonapiTestnetHost, + tetraHost = getConfig(TonNetwork.TETRA).tonapiMainnetHost, + okHttpClient = tonAPIHttpClient + ) + + private fun buildBatteryProvider() = BatteryProvider( + mainnetHost = getConfig(TonNetwork.MAINNET).batteryHost, + testnetHost = getConfig(TonNetwork.TESTNET).batteryTestnetHost, + tetraHost = getConfig(TonNetwork.TETRA).batteryHost, + okHttpClient = tonAPIHttpClient + ) + + fun getConfig(network: TonNetwork) = configRepository.getConfig(network) + + fun setCountryConfig(config: CountryConfig) = internalApi.setConfig(config) suspend fun initConfig() = configRepository.initConfig() suspend fun tonapiFetch( url: String, - options: String + options: String, + network: TonNetwork ): Response = withContext(Dispatchers.IO) { val uri = url.toUriOrNull() ?: throw Exception("Invalid URL") if (uri.scheme != "https") { throw Exception("Invalid scheme. Should be https") } - val host = uri.host ?: throw Exception("Invalid URL") - if (host != "tonapi.io" && host.endsWith(".tonapi.io")) { + + val host = runCatching { uri.host } + .getOrNull() + + if (host.isNullOrBlank()) { + throw Exception("Invalid host. Should be non-empty") + } + + val isContains = getConfig(network) + .domains + .contains(host) + + if (!isContains) { throw Exception("Invalid host. Should be tonapi.io") } @@ -132,7 +220,8 @@ class API( builder.addHeader(key, value) } } - builder.addHeader("Authorization", "Bearer ${config.tonApiV2Key}") + + builder.addHeader("Authorization", "Bearer ${getConfig(network).tonApiV2Key}") if (methodOptions.equals("POST", ignoreCase = true)) { builder.post(bodyOptions.toRequestBody(contentTypeOptions.toMediaType())) @@ -141,65 +230,54 @@ class API( tonAPIHttpClient.newCall(builder.build()).execute() } - private val provider: Provider by lazy { - Provider(config.tonapiMainnetHost, config.tonapiTestnetHost, tonAPIHttpClient) - } - - private val batteryProvider: BatteryProvider by lazy { - BatteryProvider(config.batteryHost, config.batteryTestnetHost, tonAPIHttpClient) - } - - val tron: TronApi by lazy { - TronApi(config, defaultHttpClient, batteryProvider.default.get(false)) - } + fun accounts(network: TonNetwork) = provider.accounts.get(network) - fun accounts(testnet: Boolean) = provider.accounts.get(testnet) + fun jettons(network: TonNetwork) = provider.jettons.get(network) - fun jettons(testnet: Boolean) = provider.jettons.get(testnet) + fun wallet(network: TonNetwork) = provider.wallet.get(network) - fun wallet(testnet: Boolean) = provider.wallet.get(testnet) + fun nft(network: TonNetwork) = provider.nft.get(network) - fun nft(testnet: Boolean) = provider.nft.get(testnet) + fun blockchain(network: TonNetwork) = provider.blockchain.get(network) - fun blockchain(testnet: Boolean) = provider.blockchain.get(testnet) + fun emulation(network: TonNetwork) = provider.emulation.get(network) - fun emulation(testnet: Boolean) = provider.emulation.get(testnet) + fun liteServer(network: TonNetwork) = provider.liteServer.get(network) - fun liteServer(testnet: Boolean) = provider.liteServer.get(testnet) + fun staking(network: TonNetwork) = provider.staking.get(network) - fun staking(testnet: Boolean) = provider.staking.get(testnet) + fun events(network: TonNetwork) = provider.events.get(network) - fun events(testnet: Boolean) = provider.events.get(testnet) + fun rates(network: TonNetwork) = provider.rates.get(network) - fun rates() = provider.rates.get(false) + fun battery(network: TonNetwork) = batteryProvider.default.get(network) - fun battery(testnet: Boolean) = batteryProvider.default.get(testnet) + fun batteryWallet(network: TonNetwork) = batteryProvider.wallet.get(network) - fun batteryWallet(testnet: Boolean) = batteryProvider.wallet.get(testnet) + fun batteryEmulation(network: TonNetwork) = batteryProvider.emulation.get(network) - fun batteryEmulation(testnet: Boolean) = batteryProvider.emulation.get(testnet) - - fun getBatteryConfig(testnet: Boolean): Config? { - return withRetry { battery(testnet).getConfig() } + fun getBatteryConfig(network: TonNetwork): Config? { + return withRetry { battery(network).getConfig() } } - fun getBatteryRechargeMethods(testnet: Boolean): RechargeMethods? { - return withRetry { battery(testnet).getRechargeMethods(false) } + fun getBatteryRechargeMethods(network: TonNetwork): RechargeMethods? { + return withRetry { battery(network).getRechargeMethods(false) } } - fun getOnRampData() = internalApi.getOnRampData(config.webSwapsUrl) + fun getOnRampData() = internalApi.getOnRampData(mainnetConfig.webSwapsUrl) - fun getOnRampPaymentMethods(currency: String) = internalApi.getOnRampPaymentMethods(config.webSwapsUrl, currency) + fun getOnRampPaymentMethods(currency: String) = + internalApi.getOnRampPaymentMethods(mainnetConfig.webSwapsUrl, currency) - fun getOnRampMerchants() = internalApi.getOnRampMerchants(config.webSwapsUrl) + fun getOnRampMerchants() = internalApi.getOnRampMerchants(mainnetConfig.webSwapsUrl) fun getSwapAssets(): JSONArray = runCatching { - swapApi.getSwapAssets(config.webSwapsUrl)?.let(::JSONArray) + swapApi.getSwapAssets(mainnetConfig.webSwapsUrl)?.let(::JSONArray) }.getOrNull() ?: JSONArray() @Throws suspend fun calculateOnRamp(args: OnRampArgsEntity): OnRampMerchantEntity.Data = withContext(Dispatchers.IO) { - val data = internalApi.calculateOnRamp(config.webSwapsUrl, args) ?: throw Exception("Empty response") + val data = internalApi.calculateOnRamp(mainnetConfig.webSwapsUrl, args) ?: throw Exception("Empty response") val json = JSONObject(data) val items = json.getJSONArray("items").map { OnRampMerchantEntity(it) } val suggested = json.optJSONArray("suggested")?.map { OnRampMerchantEntity(it) } ?: emptyList() @@ -215,20 +293,20 @@ class API( fun getBatteryBalance( tonProofToken: String, - testnet: Boolean, + network: TonNetwork, units: DefaultApi.UnitsGetBalance = DefaultApi.UnitsGetBalance.ton ): Balance? { - return withRetry { battery(testnet).getBalance(tonProofToken, units, region = config.region) } + return withRetry { battery(network).getBalance(tonProofToken, units, region = getConfig(network).region) } } fun getAlertNotifications() = withRetry { internalApi.getNotifications() } ?: emptyList() - private fun isOkStatus(testnet: Boolean): Boolean { + private fun isOkStatus(network: TonNetwork): Boolean { try { val status = withRetry { - provider.utilities.get(testnet).status() + provider.utilities.get(network).status() } ?: return false if (!status.restOnline) { return false @@ -244,24 +322,48 @@ class API( fun realtime( accountId: String, - testnet: Boolean, + network: TonNetwork, config: ConfigEntity, onFailure: ((Throwable) -> Unit)? ): Flow { - val endpoint = if (testnet) config.tonapiSSETestnetEndpoint else config.tonapiSSEEndpoint + val endpoint = when (network) { + TonNetwork.TESTNET -> config.tonapiSSETestnetEndpoint + TonNetwork.MAINNET -> config.tonapiSSEEndpoint + TonNetwork.TETRA -> config.tonapiSSEEndpoint + } val url = "$endpoint/sse/traces?account=$accountId&token=${config.tonApiV2Key}" - return seeHttpClient.sse(url, onFailure = onFailure) + return sseHttpClient.sse(url, onFailure = onFailure) + } + + fun realtimeV2( + accountId: String, + network: TonNetwork, + config: ConfigEntity, + onFailure: ((Throwable) -> Unit)? + ): Flow { + val endpoint = when (network) { + TonNetwork.TESTNET -> config.toncenterSSETestnetEndpoint + TonNetwork.MAINNET -> config.toncenterSSEEndpoint + TonNetwork.TETRA -> config.toncenterSSEEndpoint + } + val request = ToncenterSSERequest( + addresses = listOf(accountId), + types = listOf("transactions"), + minFinality = "pending", + ) + val url = "${endpoint}/streaming/v2/sse" + return sseHttpClient.ssePost(url, request.toJSON().toString(), onFailure = onFailure) } - suspend fun refreshConfig(testnet: Boolean) { - configRepository.refresh(testnet) + suspend fun refreshConfig() { + configRepository.refresh() } fun swapStream( from: SwapAssetParam, to: SwapAssetParam, userAddress: String - ) = swapApi.stream(config.webSwapsUrl, from, to, userAddress) + ) = swapApi.stream(mainnetConfig.webSwapsUrl, from, to, userAddress) suspend fun getPageTitle(url: String): String = withContext(Dispatchers.IO) { try { @@ -271,21 +373,27 @@ class API( val html = defaultHttpClient.get(url, headers) val ogTitle = Regex("""]*>(.*?)""", RegexOption.DOT_MATCHES_ALL) - .find(html)?.groupValues?.get(1) + .find(html) + ?.groupValues + ?.get(1) title?.trim() ?: "" } catch (e: Throwable) { @@ -300,25 +408,25 @@ class API( return defaultHttpClient.get(url, headers) } - fun getBurnAddress() = config.burnZeroDomain.ifBlank { + fun getBurnAddress() = mainnetConfig.burnZeroDomain.ifBlank { "UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKZ" } suspend fun getDnsExpiring( accountId: String, - testnet: Boolean, + network: TonNetwork, period: Int ) = withContext(Dispatchers.IO) { - withRetry { accounts(testnet).getAccountDnsExpiring(accountId, period).items } ?: emptyList() + withRetry { accounts(network).getAccountDnsExpiring(accountId, period).items } ?: emptyList() } fun getEvents( accountId: String, - testnet: Boolean, + network: TonNetwork, beforeLt: Long? = null, limit: Int = 20 ): AccountEvents? = withRetry { - accounts(testnet).getAccountEvents( + accounts(network).getAccountEvents( accountId = accountId, limit = limit, beforeLt = beforeLt, @@ -328,14 +436,14 @@ class API( fun fetchTonEvents( accountId: String, - testnet: Boolean, + network: TonNetwork, beforeLt: Long? = null, beforeTimestamp: Timestamp? = null, afterTimestamp: Timestamp? = null, limit: Int, ): List { val response = withRetry { - accounts(testnet).getAccountEvents( + accounts(network).getAccountEvents( accountId = accountId, beforeLt = beforeLt, endDate = beforeTimestamp?.seconds(), @@ -347,7 +455,7 @@ class API( return response.events } - fun fetchTronTransactions( + suspend fun fetchTronTransactions( tronAddress: String, tonProofToken: String, beforeTimestamp: Timestamp? = null, @@ -357,13 +465,13 @@ class API( suspend fun getTransactionByHash( accountId: String, - testnet: Boolean, + network: TonNetwork, hash: String, attempt: Int = 0 ): AccountEventEntity? { try { - val body = accounts(testnet).getAccountEvent(accountId, hash) - return AccountEventEntity(accountId, testnet, hash, body) + val body = accounts(network).getAccountEvent(accountId, hash) + return AccountEventEntity(accountId, network.isTestnet, hash, body) } catch (e: Throwable) { if (attempt >= 10 || e is CancellationException) { return null @@ -372,15 +480,15 @@ class API( } else { delay(1000) } - return getTransactionByHash(accountId, testnet, hash, attempt + 1) + return getTransactionByHash(accountId, network, hash, attempt + 1) } } suspend fun getSingleEvent( eventId: String, - testnet: Boolean + network: TonNetwork ): List? = withContext(Dispatchers.IO) { - val event = withRetry { events(testnet).getEvent(eventId) } ?: return@withContext null + val event = withRetry { events(network).getEvent(eventId) } ?: return@withContext null val accountEvent = AccountEvent( eventId = eventId, account = AccountAddress( @@ -402,11 +510,11 @@ class API( fun getTokenEvents( tokenAddress: String, accountId: String, - testnet: Boolean, + network: TonNetwork, beforeLt: Long? = null, limit: Int = 10 ): AccountEvents { - return accounts(testnet).getAccountJettonHistoryByID( + return accounts(network).getAccountJettonHistoryByID( jettonId = tokenAddress, accountId = accountId, limit = limit, @@ -416,10 +524,10 @@ class API( fun getTonBalance( accountId: String, - testnet: Boolean, + network: TonNetwork, currency: String, ): BalanceEntity? { - val account = getAccount(accountId, testnet, currency) ?: return null + val account = getAccount(accountId, network, currency) ?: return null val initializedAccount = account.status != AccountStatus.uninit && account.status != AccountStatus.nonexist return BalanceEntity( @@ -435,55 +543,73 @@ class API( fun getJetton( accountId: String, - testnet: Boolean + network: TonNetwork ): TokenEntity? { - val jettonsAPI = jettons(testnet) + val jettonsAPI = jettons(network) val jetton = withRetry { jettonsAPI.getJettonInfo(accountId) } ?: return null - return TokenEntity(jetton) + return jetton.toTokenEntity() } fun getJettonCustomPayload( accountId: String, - testnet: Boolean, + network: TonNetwork, jettonId: String ): TokenEntity.TransferPayload? { - val jettonsAPI = jettons(testnet) + val jettonsAPI = jettons(network) val payload = withRetry { jettonsAPI.getJettonTransferPayload(accountId, jettonId) } ?: return null - return TokenEntity.TransferPayload(tokenAddress = jettonId, payload) + return TokenEntity.TransferPayload( + tokenAddress = jettonId, + customPayload = payload.customPayload?.cellFromHex(), + stateInit = payload.stateInit?.cellFromHex()?.asRef(StateInit), + ) } fun getJettonsBalances( accountId: String, - testnet: Boolean, + network: TonNetwork, currency: String? = null, extensions: List? = null ): List? { val jettonsBalances = withRetry { - accounts(testnet).getAccountJettonsBalances( + accounts(network).getAccountJettonsBalances( accountId = accountId, currencies = currency?.let { listOf(it) }, supportedExtensions = extensions, ).balances } ?: return null - return jettonsBalances.map { BalanceEntity(it) }.filter { it.value.isPositive } + return jettonsBalances.map { jb -> + BalanceEntity( + token = jb.jetton.toTokenEntity(jb.extensions, jb.lock), + value = Coins.of( + BigDecimal(jb.balance).movePointLeft(jb.jetton.decimals), + jb.jetton.decimals + ), + walletAddress = jb.walletAddress.address, + initializedAccount = true, + isRequestMinting = jb.extensions?.contains(TokenEntity.Extension.CustomPayload.value) == true, + isTransferable = jb.extensions?.contains(TokenEntity.Extension.NonTransferable.value) != true, + numerator = jb.jetton.scaledUi?.numerator?.toBigDecimal(), + denominator = jb.jetton.scaledUi?.denominator?.toBigDecimal() + ).also { it.rates = jb.price } + }.filter { it.value.isPositive } } fun resolveAddressOrName( query: String, - testnet: Boolean + network: TonNetwork ): AccountDetailsEntity? { return try { - val account = getAccount(query, testnet, null) ?: return null - val details = AccountDetailsEntity(query, account, testnet) + val account = getAccount(query, network, null) ?: return null + val details = AccountDetailsEntity(query, account, network) if (details.walletVersion != WalletVersion.UNKNOWN) { details } else { details.copy( - walletVersion = getWalletVersionByAddress(account.address, testnet) + walletVersion = getWalletVersionByAddress(account.address, network) ) } } catch (e: Throwable) { @@ -491,31 +617,45 @@ class API( } } - private fun getWalletVersionByAddress(address: String, testnet: Boolean): WalletVersion { - val pk = getPublicKey(address, testnet) ?: return WalletVersion.UNKNOWN - return BaseWalletContract.resolveVersion(pk, address.toRawAddress(), testnet) + private fun getWalletVersionByAddress(address: String, network: TonNetwork): WalletVersion { + val pk = getPublicKey(address, network) ?: return WalletVersion.UNKNOWN + return BaseWalletContract.resolveVersion(pk, address.toRawAddress(), network) + } + + fun resolvePublicKeysBulk( + publicKeys: List, + network: TonNetwork, + installId: String? + ): List? { + val request = GetWalletsByPublicKeyBulkRequest(publicKeys = publicKeys.map { it.hex() }) + return withRetry { + wallet(network).getWalletsByPublicKeyBulk( + F = installId, + getWalletsByPublicKeyBulkRequest = request + ).items + } } fun resolvePublicKey( pk: PublicKeyEd25519, - testnet: Boolean + network: TonNetwork ): List { return try { val query = pk.hex() val wallets = withRetry { - wallet(testnet).getWalletsByPublicKey(query).accounts + wallet(network).getWalletsByPublicKey(query).accounts } ?: return emptyList() wallets.map { AccountDetailsEntity( query = query, wallet = it, - testnet = testnet + network = network ) }.map { if (it.walletVersion == WalletVersion.UNKNOWN) { it.copy( walletVersion = BaseWalletContract.resolveVersion( pk, it.address.toRawAddress(), - testnet + network ) ) } else { @@ -527,36 +667,36 @@ class API( } } - fun getRates(currency: String, tokens: List): Map? { + fun getRates(network: TonNetwork, currency: String, tokens: List): Map? { val currencies = listOf(currency, "TON") return withRetry { - rates().getRates( + rates(network).getRates( tokens = tokens, currencies = currencies ).rates } } - fun getRates(from: String, to: String): Map? { + fun getRates(network: TonNetwork, from: String, to: String): Map? { return withRetry { - rates().getRates( + rates(network).getRates( tokens = listOf(from), currencies = listOf(to) ).rates } } - fun getNft(address: String, testnet: Boolean): NftItem? { - return withRetry { nft(testnet).getNftItemByAddress(address) } + fun getNft(address: String, network: TonNetwork): NftItem? { + return withRetry { nft(network).getNftItemByAddress(address) } } fun getNftItems( address: String, - testnet: Boolean, + network: TonNetwork, limit: Int = 1000 ): List? { return withRetry { - accounts(testnet).getAccountNftItems( + accounts(network).getAccountNftItems( accountId = address, limit = limit, indirectOwnership = true, @@ -566,18 +706,18 @@ class API( private fun getPublicKey( accountId: String, - testnet: Boolean + network: TonNetwork ): PublicKeyEd25519? { val hex = withRetry { - accounts(testnet).getAccountPublicKey(accountId) + accounts(network).getAccountPublicKey(accountId) }?.publicKey ?: return null return PublicKeyEd25519(hex(hex)) } fun safeGetPublicKey( accountId: String, - testnet: Boolean - ) = getPublicKey(accountId, testnet) ?: EmptyPrivateKeyEd25519.publicKey() + network: TonNetwork + ) = getPublicKey(accountId, network) ?: EmptyPrivateKeyEd25519.publicKey() fun tonconnectEvents( publicKeys: List, @@ -589,12 +729,12 @@ class API( } val value = publicKeys.joinToString(",") val url = "${bridgeUrl}/events?client_id=$value" - return seeHttpClient.sse(url, lastEventId, onFailure).filter { it.type == "message" } + return sseHttpClient.sse(url, lastEventId, onFailure).filter { it.type == "message" } } fun tonconnectPayload(): String? { try { - val url = "${config.tonapiMainnetHost}/v2/tonconnect/payload" + val url = "${mainnetConfig.tonapiMainnetHost}/v2/tonconnect/payload" val json = withRetry { JSONObject(tonAPIHttpClient.get(url)) } ?: return null @@ -604,10 +744,10 @@ class API( } } - suspend fun batteryVerifyPurchasePromo(testnet: Boolean, code: String): Boolean = + suspend fun batteryVerifyPurchasePromo(network: TonNetwork, code: String): Boolean = withContext(Dispatchers.IO) { try { - battery(testnet).verifyPurchasePromo(code) + battery(network).verifyPurchasePromo(code) true } catch (e: Throwable) { false @@ -615,7 +755,7 @@ class API( } fun tonconnectProof(address: String, proof: String): String { - val url = "${config.tonapiMainnetHost}/v2/wallet/auth/proof" + val url = "${mainnetConfig.tonapiMainnetHost}/v2/wallet/auth/proof" val data = "{\"address\":\"$address\",\"proof\":$proof}" val response = withRetry { tonAPIHttpClient.postJSON(url, data) @@ -643,29 +783,33 @@ class API( tonProofToken: String, jettonMaster: String, cell: Cell, - testnet: Boolean, + network: TonNetwork, ): String? { val request = EstimateGaslessCostRequest(cell.base64(), false) return withRetry { - battery(testnet).estimateGaslessCost(jettonMaster, request, tonProofToken).commission + battery(network).estimateGaslessCost(jettonMaster, request, tonProofToken).commission } } fun emulateWithBattery( tonProofToken: String, cell: Cell, - testnet: Boolean, + network: TonNetwork, safeModeEnabled: Boolean, - ) = emulateWithBattery(tonProofToken, cell.base64(), testnet, safeModeEnabled) + ) = emulateWithBattery(tonProofToken, cell.base64(), network, safeModeEnabled) fun emulateWithBattery( tonProofToken: String, boc: String, - testnet: Boolean, + network: TonNetwork, safeModeEnabled: Boolean, - ): Pair? { - val host = if (testnet) config.batteryTestnetHost else config.batteryHost + ): EmulateWithBatteryResult? { + val host = when (network) { + TonNetwork.TESTNET -> mainnetConfig.batteryTestnetHost + TonNetwork.MAINNET -> mainnetConfig.batteryHost + TonNetwork.TETRA -> mainnetConfig.batteryHost + } val url = "$host/wallet/emulate" val data = "{\"boc\":\"$boc\",\"safe_mode\":$safeModeEnabled}" @@ -677,20 +821,26 @@ class API( val supportedByBattery = response.headers["supported-by-battery"] == "true" val allowedByBattery = response.headers["allowed-by-battery"] == "true" + val excess = response.headers["excess"]?.toLongOrNull() val withBattery = supportedByBattery && allowedByBattery - val string = response.body?.string() ?: return null + val string = response.body.use { it.string() } + if (string.isBlank()) { + L.e("emulateWithBattery", "Empty response body, code=${response.code}") + return null + } val consequences = try { Serializer.JSON.decodeFromString(string) } catch (e: Throwable) { + L.e(e) return null } - return Pair(consequences, withBattery) + return EmulateWithBatteryResult(consequences, withBattery, excess) } suspend fun emulate( boc: String, - testnet: Boolean, + network: TonNetwork, address: String? = null, balance: Long? = null, safeModeEnabled: Boolean, @@ -705,49 +855,50 @@ class API( // safeMode = safeModeEnabled ) withRetry { - emulation(testnet).emulateMessageToWallet(request) + emulation(network).emulateMessageToWallet(request) } } suspend fun emulate( cell: Cell, - testnet: Boolean, + network: TonNetwork, address: String? = null, balance: Long? = null, safeModeEnabled: Boolean, ): MessageConsequences? { - return emulate(cell.hex(), testnet, address, balance, safeModeEnabled) + return emulate(cell.hex(), network, address, balance, safeModeEnabled) } suspend fun sendToBlockchainWithBattery( boc: String, tonProofToken: String, - testnet: Boolean, + network: TonNetwork, source: String, confirmationTime: Double, - ): SendBlockchainState = withContext(Dispatchers.IO) { - if (!isOkStatus(testnet)) { - return@withContext SendBlockchainState.STATUS_ERROR + ) = withContext(Dispatchers.IO) { + if (!isOkStatus(network)) { + throw SendBlockchainException.SendBlockchainStatusException } val request = io.batteryapi.models.EmulateMessageToWalletRequest( boc = boc, ) - withRetry { - battery(testnet).sendMessage(tonProofToken, request) - SendBlockchainState.SUCCESS - } ?: SendBlockchainState.UNKNOWN_ERROR + try { + battery(network).sendMessage(tonProofToken, request) + } catch (e: Throwable) { + throwSendBlockchainError(e) + } } suspend fun sendToBlockchain( boc: String, - testnet: Boolean, + network: TonNetwork, source: String, confirmationTime: Double, - ): SendBlockchainState = withContext(Dispatchers.IO) { - if (!isOkStatus(testnet)) { - return@withContext SendBlockchainState.STATUS_ERROR + ) = withContext(Dispatchers.IO) { + if (!isOkStatus(network)) { + throw SendBlockchainException.SendBlockchainStatusException } val meta = hashMapOf( @@ -760,26 +911,27 @@ class API( boc = boc, meta = meta ) - withRetry { - blockchain(testnet).sendBlockchainMessage(request) - SendBlockchainState.SUCCESS - } ?: SendBlockchainState.UNKNOWN_ERROR + try { + blockchain(network).sendBlockchainMessage(request) + } catch (e: Throwable) { + throwSendBlockchainError(e) + } } fun getAccountSeqno( accountId: String, - testnet: Boolean, - ): Int = withRetry { wallet(testnet).getAccountSeqno(accountId).seqno } ?: 0 + network: TonNetwork, + ): Int = withRetry { wallet(network).getAccountSeqno(accountId).seqno } ?: 0 suspend fun resolveAccount( value: String, - testnet: Boolean, + network: TonNetwork, ): Account? = withContext(Dispatchers.IO) { /*if (value.isValidTonAddress()) { - return@withContext getAccount(value, testnet) + return@withContext getAccount(value, network) } - return@withContext resolveDomain(value.lowercase().trim(), testnet)*/ - getAccount(value, testnet, null) + return@withContext resolveDomain(value.lowercase().trim(), network)*/ + getAccount(value, network, null) } /*private suspend fun resolveDomain(domain: String, testnet: Boolean): Account? { @@ -788,7 +940,7 @@ class API( private fun getAccount( accountId: String, - testnet: Boolean, + network: TonNetwork, currency: String?, ): Account? { var normalizedAccountId = accountId @@ -802,7 +954,7 @@ class API( if (!normalizedAccountId.isValidTonAddress()) { normalizedAccountId = normalizedAccountId.lowercase().trim() } - return withRetry { accounts(testnet).getAccount(normalizedAccountId) } + return withRetry { accounts(network).getAccount(normalizedAccountId) } } fun pushSubscribe( @@ -814,7 +966,7 @@ class API( if (accounts.isEmpty()) { return true } - val url = "${config.tonapiMainnetHost}/v1/internal/pushes/plain/subscribe" + val url = "${mainnetConfig.tonapiMainnetHost}/v1/internal/pushes/plain/subscribe" val accountsArray = JSONArray() for (account in accounts) { @@ -843,7 +995,7 @@ class API( return true } - val url = "${config.tonapiMainnetHost}/v1/internal/pushes/plain/unsubscribe" + val url = "${mainnetConfig.tonapiMainnetHost}/v1/internal/pushes/plain/unsubscribe" val accountsArray = JSONArray() for (account in accounts) { @@ -873,7 +1025,7 @@ class API( commercial: Boolean, silent: Boolean ): Boolean { - val url = "${config.tonapiMainnetHost}/v1/internal/pushes/tonconnect" + val url = "${mainnetConfig.tonapiMainnetHost}/v1/internal/pushes/tonconnect" val json = JSONObject() json.put("app_url", appUrl) @@ -904,7 +1056,7 @@ class API( ): Boolean { return try { val uriBuilder = - Uri.parse("${config.tonapiMainnetHost}/v1/internal/pushes/tonconnect").buildUpon() + Uri.parse("${mainnetConfig.tonapiMainnetHost}/v1/internal/pushes/tonconnect").buildUpon() uriBuilder.appendQueryParameter("firebase_token", firebaseToken) uriBuilder.appendQueryParameter("app_url", appUrl) uriBuilder.appendQueryParameter("account", accountId) @@ -924,7 +1076,7 @@ class API( accountId: String, ): JSONArray { return try { - val url = "${config.tonapiMainnetHost}/v1/messages/history?account=$accountId" + val url = "${mainnetConfig.tonapiMainnetHost}/v1/messages/history?account=$accountId" val response = withRetry { tonAPIHttpClient.get(url, ArrayMap().apply { set("X-TonConnect-Auth", token) @@ -937,17 +1089,25 @@ class API( } } - fun getBrowserApps(testnet: Boolean, locale: Locale): JSONObject { - return internalApi.getBrowserApps(testnet, locale) + fun getBrowserApps(network: TonNetwork, locale: Locale): JSONObject { + return internalApi.getBrowserApps(network, locale) + } + + fun getBanners(network: TonNetwork): List { + return internalApi.getBanners(network) + } + + fun getCurrencies(network: TonNetwork, locale: Locale): JSONArray { + return internalApi.getCurrencies(network, locale) } - fun getFiatMethods(testnet: Boolean, locale: Locale): JSONObject? { - return withRetry { internalApi.getFiatMethods(testnet, locale) } + fun getFiatMethods(network: TonNetwork, locale: Locale): JSONObject? { + return withRetry { internalApi.getFiatMethods(network, locale) } } - fun getTransactionEvents(accountId: String, testnet: Boolean, eventId: String): AccountEvent? { + fun getTransactionEvents(accountId: String, network: TonNetwork, eventId: String): AccountEvent? { return try { - accounts(testnet).getAccountEvent(accountId, eventId) + accounts(network).getAccountEvent(accountId, eventId) } catch (e: Throwable) { null } @@ -965,7 +1125,7 @@ class API( ): List { try { val url = - "${config.tonapiMainnetHost}/v2/rates/chart?token=$token¤cy=$currency&start_date=$startDate&end_date=$endDate" + "${mainnetConfig.tonapiMainnetHost}/v2/rates/chart?token=$token¤cy=$currency&start_date=$startDate&end_date=$endDate" val array = JSONObject(tonAPIHttpClient.get(url)).getJSONArray("points") return (0 until array.length()).map { index -> ChartEntity(array.getJSONArray(index)) @@ -975,18 +1135,18 @@ class API( } } - fun getServerTime(testnet: Boolean): Int { - /*val time = serverTimeProvider.getServerTime(testnet) + fun getServerTime(network: TonNetwork): Int { + /*val time = serverTimeProvider.getServerTime(network) if (time == null) { - val serverTimeSeconds = withRetry { liteServer(testnet).getRawTime().time } + val serverTimeSeconds = withRetry { liteServer(network).getRawTime().time } if (serverTimeSeconds == null) { return (System.currentTimeMillis() / 1000).toInt() } - serverTimeProvider.setServerTime(testnet, serverTimeSeconds) + serverTimeProvider.setServerTime(network, serverTimeSeconds) return serverTimeSeconds } return time*/ - val serverTimeSeconds = withRetry { liteServer(testnet).getRawTime().time } + val serverTimeSeconds = withRetry { liteServer(network).getRawTime().time } if (serverTimeSeconds == null) { return (System.currentTimeMillis() / 1000).toInt() } @@ -999,7 +1159,7 @@ class API( nftAddress: String, scam: Boolean ) = withContext(Dispatchers.IO) { - val url = config.scamEndpoint + "/v1/report/$nftAddress" + val url = mainnetConfig.scamEndpoint + "/v1/report/$nftAddress" val data = "{\"is_scam\":$scam}" val response = withRetry { tonAPIHttpClient.postJSON(url, data) @@ -1015,7 +1175,7 @@ class API( comment: String?, recipient: String, ) = withContext(Dispatchers.IO) { - val url = config.scamEndpoint + "/v1/report/tx/$txId" + val url = mainnetConfig.scamEndpoint + "/v1/report/tx/$txId" val json = JSONObject() json.put("recipient", recipient) comment?.let { json.put("comment", it) } @@ -1028,4 +1188,19 @@ class API( } response.body?.string() ?: throw Exception("Empty response") } -} \ No newline at end of file + + private fun throwSendBlockchainError(e: Throwable): Nothing { + val errorText = when (e) { + is ClientException -> (e.response as? ClientError<*>)?.body?.parseJsonError() + is ServerException -> (e.response as? ServerError<*>)?.body?.parseJsonError() + else -> null + } + throw SendBlockchainException.SendBlockchainErrorException(e, errorText) + } + + private fun Any.parseJsonError(): String? = try { + JSONObject(toString()).getString("error") + } catch (e: Exception) { + null + } +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/BatteryProvider.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/BatteryProvider.kt index 0ef717b48..3d7dbc8d9 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/BatteryProvider.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/BatteryProvider.kt @@ -7,17 +7,17 @@ import okhttp3.OkHttpClient internal class BatteryProvider( mainnetHost: String, testnetHost: String, + tetraHost: String, okHttpClient: OkHttpClient, ) { private val main = BatteryAPI(mainnetHost, okHttpClient) private val test = BatteryAPI(testnetHost, okHttpClient) + private val tetra = BatteryAPI(tetraHost, okHttpClient) - val default = SourceAPI(main.default, test.default) + val default = SourceAPI(main.default, test.default, tetra.default) - val emulation = SourceAPI(main.emulation, test.emulation) + val emulation = SourceAPI(main.emulation, test.emulation, tetra.emulation) - val wallet = SourceAPI(main.wallet, test.wallet) - - -} \ No newline at end of file + val wallet = SourceAPI(main.wallet, test.wallet, tetra.wallet) +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Constants.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Constants.kt index eed64766c..f12a54e86 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Constants.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Constants.kt @@ -1,6 +1,6 @@ package com.tonapps.wallet.api internal object Constants { - - const val SWAP_PREFIX = "https://swap.tonkeeper.com" + const val SWAP_API = "https://swap.tonkeeper.com" + const val TRADING_API = "https://trading.tonkeeper.com" } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/CoreAPI.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/CoreAPI.kt index e11e3a200..3e9312b30 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/CoreAPI.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/CoreAPI.kt @@ -2,64 +2,94 @@ package com.tonapps.wallet.api import android.content.Context import android.os.Build -import com.google.android.gms.net.CronetProviderInstaller -import com.google.firebase.crashlytics.BuildConfig +import com.tonapps.apps.wallet.api.BuildConfig +import com.tonapps.core.flags.WalletFeature +import com.tonapps.extensions.Os import com.tonapps.extensions.appVersionName import com.tonapps.extensions.cacheFolder import com.tonapps.extensions.locale import com.tonapps.network.interceptor.AcceptLanguageInterceptor import com.tonapps.network.interceptor.AuthorizationInterceptor -import com.tonapps.wallet.api.cronet.CronetInterceptor +import com.tonapps.network.interceptor.LoggingInterceptor +import com.tonapps.network.interceptor.RateLimitInterceptor import com.tonapps.wallet.api.entity.ConfigEntity +import okhttp3.Cache import okhttp3.Interceptor import okhttp3.OkHttpClient -import org.chromium.net.CronetEngine import java.util.concurrent.TimeUnit -abstract class CoreAPI(private val context: Context) { +//import org.chromium.net.CronetEngine +//import com.google.android.gms.net.CronetProviderInstaller +//import com.google.net.cronet.okhttptransport.CronetInterceptor - val appVersionName = context.appVersionName +abstract class CoreAPI( + private val context: Context, + private val delegate: LoggingInterceptor.Delegate, +) { - private val userAgent = "Tonkeeper/${appVersionName} (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})" + val appVersionName = context.appVersionName + // e.g. Tonkeeper/1.2.3 (Android; 14; Pixel Tablet) + private val userAgent = "Tonkeeper/${appVersionName} (Android; ${Build.VERSION.RELEASE}; ${Os.deviceNameAndModel()})" - private var cronetEngine: CronetEngine? = null +// private var cronetEngine: CronetEngine? = null val defaultHttpClient = baseOkHttpClientBuilder( - cronetEngine = { cronetEngine }, +// cronetEngine = { cronetEngine }, timeoutSeconds = 30, + rateLimit = 15, + context = context, interceptors = listOf( UserAgentInterceptor(userAgent), - ) + ), + delegate = delegate, ).build() - val seeHttpClient = baseOkHttpClientBuilder( - cronetEngine = { null }, - timeoutSeconds = 60, + + val tronHttpClient = baseOkHttpClientBuilder( +// cronetEngine = { cronetEngine }, + timeoutSeconds = 30, + rateLimit = 10, + context = context, interceptors = listOf( UserAgentInterceptor(userAgent), - ) + ), + delegate = delegate, ).build() - init { - if (!BuildConfig.DEBUG) { - requestCronet(context, userAgent) { - cronetEngine = it - } - } + fun sseHttpClient(config: () -> ConfigEntity): OkHttpClient { + return createTonAPIHttpClient( +// cronetEngine = { null }, + timeoutSeconds = 60, + callTimeoutSeconds = 0, + context = context, + userAgent = userAgent, + tonApiV2Key = { config().tonApiV2Key }, + allowDomains = { config().domains }, + delegate = delegate, + ) } +// init { +// requestCronet(context, userAgent) { +// cronetEngine = it +// } +// } + fun tonAPIHttpClient(config: () -> ConfigEntity): OkHttpClient { return createTonAPIHttpClient( context = context, userAgent = userAgent, tonApiV2Key = { config().tonApiV2Key }, allowDomains = { config().domains }, - cronetEngine = { cronetEngine } + delegate = delegate, +// cronetEngine = { cronetEngine } ) } private companion object { + const val MAX_CACHE_SIZE = 500L * 1024 * 1024 + class UserAgentInterceptor(private val userAgent: String) : Interceptor { override fun intercept(chain: Interceptor.Chain): okhttp3.Response { val request = chain.request().newBuilder() @@ -69,18 +99,49 @@ abstract class CoreAPI(private val context: Context) { } } + class XCapabilityInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): okhttp3.Response { + if (!WalletFeature.StreamingV2.isEnabled) { + return chain.proceed(chain.request()) + } + + val request = chain.request().newBuilder() + .addHeader("X-Capability", "sub-second") + .build() + return chain.proceed(request) + } + } + private fun baseOkHttpClientBuilder( - cronetEngine: () -> CronetEngine?, timeoutSeconds: Long = 30, - interceptors: List = emptyList() + callTimeoutSeconds: Long = timeoutSeconds, + rateLimit: Int = 10, + interceptors: List = emptyList(), + context: Context, + delegate: LoggingInterceptor.Delegate, +// cronetEngine: () -> CronetEngine?, ): OkHttpClient.Builder { val builder = OkHttpClient().newBuilder() .retryOnConnectionFailure(true) .connectTimeout(timeoutSeconds, TimeUnit.SECONDS) .readTimeout(timeoutSeconds, TimeUnit.SECONDS) .writeTimeout(timeoutSeconds, TimeUnit.SECONDS) - .callTimeout(timeoutSeconds, TimeUnit.SECONDS) + .callTimeout(callTimeoutSeconds, TimeUnit.SECONDS) .pingInterval(timeoutSeconds, TimeUnit.SECONDS) + .cache( + Cache( + context.cacheFolder("cronet"), + MAX_CACHE_SIZE + ) + ) + // TODO additional setup like connectionPool etc +// .connectionPool( +// ConnectionPool( +// maxIdleConnections = 16, +// keepAliveDuration = 30, +// timeUnit = TimeUnit.SECONDS, +// ) +// ) .followSslRedirects(true) .followRedirects(true) @@ -88,11 +149,20 @@ abstract class CoreAPI(private val context: Context) { builder.addInterceptor(interceptor) } - builder.addInterceptor { chain -> - cronetEngine()?.let { engine -> - CronetInterceptor.newBuilder(engine).build() - }?.intercept(chain) ?: chain.proceed(chain.request()) - } +// cronetEngine()?.let { engine -> +// builder.addInterceptor( +// CronetInterceptor.newBuilder(engine) +// .build() +// ) +// } + + builder.addInterceptor( + RateLimitInterceptor(requestsPerSecondLimit = rateLimit) + ) + + builder.addInterceptor( + LoggingInterceptor(delegate = delegate) + ) return builder } @@ -100,47 +170,56 @@ abstract class CoreAPI(private val context: Context) { private fun createTonAPIHttpClient( userAgent: String, context: Context, - cronetEngine: () -> CronetEngine?, + timeoutSeconds: Long = 30, + callTimeoutSeconds: Long = timeoutSeconds, + rateLimit: Int = 10, tonApiV2Key: () -> String, - allowDomains: () -> List + allowDomains: () -> List, + delegate: LoggingInterceptor.Delegate, +// cronetEngine: () -> CronetEngine?, ): OkHttpClient { - val interceptors = listOf( + val interceptors = mutableListOf( UserAgentInterceptor(userAgent), + XCapabilityInterceptor(), AcceptLanguageInterceptor(context.locale), AuthorizationInterceptor.bearer( token = tonApiV2Key, allowDomains = allowDomains - ) + ), ) return baseOkHttpClientBuilder( - cronetEngine = cronetEngine, - interceptors = interceptors + context = context, + interceptors = interceptors, + delegate = delegate, +// cronetEngine = cronetEngine, ).build() } - private fun requestCronet(context: Context, userAgent: String, callback: (CronetEngine) -> Unit) { - CronetProviderInstaller.installProvider(context).addOnSuccessListener { - build(context, userAgent)?.let(callback) - } - } - - private fun build(context: Context, userAgent: String): CronetEngine? { - if (!CronetProviderInstaller.isInstalled()) { - return null - } - return try { - CronetEngine.Builder(context) - .setUserAgent(userAgent) - .enableQuic(true) - .enableHttp2(true) - .enableBrotli(true) - .setStoragePath(context.cacheFolder("cronet").absolutePath) - .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 500 * 1024 * 1024) - .build() - } catch (e: Throwable) { - null - } - } +// private fun requestCronet(context: Context, userAgent: String, callback: (CronetEngine) -> Unit) { +// CronetProviderInstaller.installProvider(context) +// .addOnSuccessListener { +// build(context, userAgent)?.let(callback) +// } +// } +// +// private fun build(context: Context, userAgent: String): CronetEngine? { +// if (!CronetProviderInstaller.isInstalled()) { +// return null +// } +// +// return try { +// CronetEngine.Builder(context) +// .setUserAgent(userAgent) +// .enableQuic(true) +// .enableHttp2(true) +// .enableBrotli(true) +// .setStoragePath(context.cacheFolder("cronet").absolutePath) +// .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, 500 * 1024 * 1024) +// .build() +// } catch (e: Throwable) { +// null +// } +// } } } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Extensions.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Extensions.kt index 3719a108f..44b5735b2 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Extensions.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Extensions.kt @@ -1,19 +1,21 @@ package com.tonapps.wallet.api import android.os.SystemClock -import android.util.Log import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tonapps.log.L import com.tonapps.network.OkHttpError +import com.tonapps.network.backoff.ExponentialBackoff import io.infrastructure.ClientError import io.infrastructure.ClientException import io.infrastructure.ServerError import kotlinx.coroutines.CancellationException import kotlinx.io.IOException +import okhttp3.Response import java.net.SocketTimeoutException fun withRetry( times: Int = 5, - delay: Long = 500, + backoff: ExponentialBackoff = ExponentialBackoff(), retryBlock: () -> R ): R? { var index = -1 @@ -24,27 +26,28 @@ fun withRetry( } catch (e: CancellationException) { throw e } catch (e: SocketTimeoutException) { - Log.e("RetryLogNew", "SocketTimeoutException occurred: ${e.message}", e) - SystemClock.sleep(delay + 100) + L.e("RetryLogNew", "SocketTimeoutException occurred: ${e.message}", e) return null } catch (e: IOException) { - Log.e("RetryLogNew", "IOException occurred: ${e.message}", e) - SystemClock.sleep(delay + 100) + L.e("RetryLogNew", "IOException occurred: ${e.message}", e) return null } catch (e: Throwable) { - Log.e("RetryLogNew", "Error occurred: ${e.message}", e) + L.e("RetryLogNew", "Error occurred: ${e.message}", e) val statusCode = e.getHttpStatusCode() + if (statusCode == 429 || statusCode == 401 || statusCode == 502 || statusCode == 520) { - SystemClock.sleep(delay + 100) + SystemClock.sleep(backoff.getDelayMs(index)) continue } + if (statusCode >= 500 || statusCode == 404 || statusCode == 400) { return null } + FirebaseCrashlytics.getInstance().recordException(e) } - } while (index < times) + return null } @@ -64,10 +67,14 @@ fun Throwable.getDebugMessage(): String? { } } +fun Response.readBody(): String { + return use { body.string() } +} + private fun ClientException.getHttpBodyMessage(): String { return when (response) { is ClientError<*> -> (response as ClientError<*>).body.toString() is ServerError<*> -> (response as ServerError<*>).body.toString() else -> response.toString() } -} \ No newline at end of file +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/FileDownloader.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/FileDownloader.kt index f1bd47e2c..02ad47487 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/FileDownloader.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/FileDownloader.kt @@ -1,6 +1,6 @@ package com.tonapps.wallet.api -import android.util.Log +import com.tonapps.log.L import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import okhttp3.OkHttpClient diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Module.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Module.kt index e8459d461..8b12469d9 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Module.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Module.kt @@ -3,5 +3,5 @@ package com.tonapps.wallet.api import org.koin.dsl.module val apiModule = module { - single(createdAtStart = true) { API(get(), get()) } + single(createdAtStart = true) { API(get(), get(), get()) } } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Provider.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Provider.kt index dd14fd69f..cb6c990e5 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Provider.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/Provider.kt @@ -7,41 +7,43 @@ import okhttp3.OkHttpClient internal class Provider( mainnetHost: String, testnetHost: String, + tetraHost: String, okHttpClient: OkHttpClient, ) { private val main = BaseAPI(mainnetHost, okHttpClient) private val test = BaseAPI(testnetHost, okHttpClient) + private val tetra = BaseAPI(tetraHost, okHttpClient) - val accounts = SourceAPI(main.accounts, test.accounts) + val accounts = SourceAPI(main.accounts, test.accounts, tetra.accounts) - val blockchain = SourceAPI(main.blockchain, test.blockchain) + val blockchain = SourceAPI(main.blockchain, test.blockchain, tetra.blockchain) - val connect = SourceAPI(main.connect, test.connect) + val connect = SourceAPI(main.connect, test.connect, tetra.connect) - val dns = SourceAPI(main.dns, test.dns) + val dns = SourceAPI(main.dns, test.dns, tetra.dns) - val emulation = SourceAPI(main.emulation, test.emulation) + val emulation = SourceAPI(main.emulation, test.emulation, tetra.emulation) - val events = SourceAPI(main.events, test.events) + val events = SourceAPI(main.events, test.events, tetra.events) - val jettons = SourceAPI(main.jettons, test.jettons) + val jettons = SourceAPI(main.jettons, test.jettons, tetra.jettons) - val liteServer = SourceAPI(main.liteServer, test.liteServer) + val liteServer = SourceAPI(main.liteServer, test.liteServer, tetra.liteServer) - val nft = SourceAPI(main.nft, test.nft) + val nft = SourceAPI(main.nft, test.nft, tetra.nft) - val rates = SourceAPI(main.rates, test.rates) + val rates = SourceAPI(main.rates, test.rates, tetra.rates) - val staking = SourceAPI(main.staking, test.staking) + val staking = SourceAPI(main.staking, test.staking, tetra.staking) - val storage = SourceAPI(main.storage, test.storage) + val storage = SourceAPI(main.storage, test.storage, tetra.storage) - val traces = SourceAPI(main.traces, test.traces) + val traces = SourceAPI(main.traces, test.traces, tetra.traces) - val wallet = SourceAPI(main.wallet, test.wallet) + val wallet = SourceAPI(main.wallet, test.wallet, tetra.wallet) - val gasless = SourceAPI(main.gasless, test.gasless) + val gasless = SourceAPI(main.gasless, test.gasless, tetra.gasless) - val utilities = SourceAPI(main.utilities, test.utilities) + val utilities = SourceAPI(main.utilities, test.utilities, tetra.utilities) } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/SendBlockchainState.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/SendBlockchainState.kt deleted file mode 100644 index 29ac90fc2..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/SendBlockchainState.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.tonapps.wallet.api - -enum class SendBlockchainState { - SUCCESS, STATUS_ERROR, UNKNOWN_ERROR -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/ServerTimeProvider.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/ServerTimeProvider.kt index b409a3e61..69908ae15 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/ServerTimeProvider.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/ServerTimeProvider.kt @@ -2,6 +2,7 @@ package com.tonapps.wallet.api import android.content.Context import android.os.SystemClock +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.prefs import androidx.core.content.edit @@ -14,26 +15,26 @@ internal class ServerTimeProvider(context: Context) { // 24 hours in milliseconds private const val CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000L - private fun getServerTimePrefKey(testnet: Boolean) = "${SERVER_TIME_KEY}_${if (testnet) "test" else "main"}" + private fun getServerTimePrefKey(network: TonNetwork) = "${SERVER_TIME_KEY}_${network.name.lowercase()}" - private fun getLocalTimePrefKey(testnet: Boolean) = "${LOCAL_TIME_KEY}_${if (testnet) "test" else "main"}" + private fun getLocalTimePrefKey(network: TonNetwork) = "${LOCAL_TIME_KEY}_${network.name.lowercase()}" } private val prefs = context.prefs("server_time") - fun setServerTime(testnet: Boolean, serverTimeSeconds: Int) { + fun setServerTime(network: TonNetwork, serverTimeSeconds: Int) { val localTimeMillis = SystemClock.elapsedRealtime() prefs.edit { - putInt(getServerTimePrefKey(testnet), serverTimeSeconds) - putLong(getLocalTimePrefKey(testnet), localTimeMillis) + putInt(getServerTimePrefKey(network), serverTimeSeconds) + putLong(getLocalTimePrefKey(network), localTimeMillis) } } - fun getServerTime(testnet: Boolean): Int? { - val savedServerSeconds = prefs.getInt(getServerTimePrefKey(testnet), 0) - val savedLocalMillis = prefs.getLong(getLocalTimePrefKey(testnet), 0L) + fun getServerTime(network: TonNetwork): Int? { + val savedServerSeconds = prefs.getInt(getServerTimePrefKey(network), 0) + val savedLocalMillis = prefs.getLong(getLocalTimePrefKey(network), 0L) if (0 >= savedServerSeconds || 0 >= savedLocalMillis) { return null } diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/configs/CountryConfig.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/configs/CountryConfig.kt new file mode 100644 index 000000000..fc9a3ffe3 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/configs/CountryConfig.kt @@ -0,0 +1,9 @@ +package com.tonapps.wallet.api.configs + +data class CountryConfig( + val deviceCountry: String? = null, + val storeCountry: String? = null, + val simCountry: String? = null, + val timezone: String? = null, + val isVpn: Boolean? = null, +) diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/ExchangeAPI.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/ExchangeAPI.kt new file mode 100644 index 000000000..8a82d42e9 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/ExchangeAPI.kt @@ -0,0 +1,15 @@ +package com.tonapps.wallet.api.core + +import io.exchangeapi.apis.ExchangeApi +import io.exchangeapi.apis.P2pApi +import io.exchangeapi.apis.SwapApi +import okhttp3.OkHttpClient + +class ExchangeAPI( + basePath: String, + okHttpClient: OkHttpClient +) { + val swap: SwapApi by lazy { SwapApi(basePath, okHttpClient) } + val exchange: ExchangeApi by lazy { ExchangeApi(basePath, okHttpClient) } + val p2p: P2pApi by lazy { P2pApi(basePath, okHttpClient) } +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/SourceAPI.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/SourceAPI.kt index 4997d24c1..575a85536 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/SourceAPI.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/SourceAPI.kt @@ -1,11 +1,18 @@ package com.tonapps.wallet.api.core +import com.tonapps.blockchain.ton.TonNetwork + data class SourceAPI( private val mainnetAPI: A, - private val testnetAPI: A + private val testnetAPI: A, + private val tetraAPI: A, ) { - fun get(testnet: Boolean): A { - return if (testnet) testnetAPI else mainnetAPI + fun get(network: TonNetwork): A { + return when (network) { + TonNetwork.TESTNET -> testnetAPI + TonNetwork.MAINNET -> mainnetAPI + TonNetwork.TETRA -> tetraAPI + } } } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/TradingAPI.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/TradingAPI.kt new file mode 100644 index 000000000..32d18e883 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/core/TradingAPI.kt @@ -0,0 +1,13 @@ +package com.tonapps.wallet.api.core + +import io.tradingapi.apis.AssetsApi +import io.tradingapi.apis.ShelvesApi +import okhttp3.OkHttpClient + +class TradingAPI( + basePath: String, + okHttpClient: OkHttpClient +) { + val shelves: ShelvesApi by lazy { ShelvesApi(basePath, okHttpClient) } + val assets: AssetsApi by lazy { AssetsApi(basePath, okHttpClient) } +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetCallFactory.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetCallFactory.java deleted file mode 100644 index f5dd25629..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetCallFactory.java +++ /dev/null @@ -1,312 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import static com.google.firebase.components.Preconditions.checkArgument; -import static com.google.firebase.components.Preconditions.checkNotNull; -import static com.google.firebase.components.Preconditions.checkState; -import static java.util.concurrent.TimeUnit.MILLISECONDS; - -import android.util.Log; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import java.io.IOException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.Request; -import okhttp3.Response; -import okio.AsyncTimeout; -import okio.Timeout; -import org.chromium.net.CronetEngine; - -/** A {@link Call.Factory} implementation using Cronet as the transport layer. */ -public final class CronetCallFactory implements Call.Factory { - - private static final String TAG = "CronetCallFactory"; - - private final RequestResponseConverter converter; - private final ExecutorService responseCallbackExecutor; - private final int readTimeoutMillis; - private final int writeTimeoutMillis; - private final int callTimeoutMillis; - - private CronetCallFactory( - RequestResponseConverter converter, - ExecutorService responseCallbackExecutor, - int readTimeoutMillis, - int writeTimeoutMillis, - int callTimeoutMillis) { - checkArgument(readTimeoutMillis >= 0, "Read timeout mustn't be negative!"); - checkArgument(writeTimeoutMillis >= 0, "Write timeout mustn't be negative!"); - checkArgument(callTimeoutMillis >= 0, "Call timeout mustn't be negative!"); - - this.converter = converter; - this.responseCallbackExecutor = responseCallbackExecutor; - this.readTimeoutMillis = readTimeoutMillis; - this.writeTimeoutMillis = writeTimeoutMillis; - this.callTimeoutMillis = callTimeoutMillis; - } - - public static Builder newBuilder(CronetEngine cronetEngine) { - return new Builder(cronetEngine); - } - - @Override - public Call newCall(Request request) { - return new CronetCall(request, this, converter, responseCallbackExecutor); - } - - private static class CronetCall implements Call { - - private final Request okHttpRequest; - private final CronetCallFactory motherFactory; - private final RequestResponseConverter converter; - private final ExecutorService responseCallbackExecutor; - - private final AtomicBoolean executed = new AtomicBoolean(); - private final AtomicBoolean canceled = new AtomicBoolean(); - private final AtomicReference convertedRequestAndResponse = - new AtomicReference<>(); - private final AsyncTimeout timeout; - - private CronetCall( - Request okHttpRequest, - CronetCallFactory motherFactory, - RequestResponseConverter converter, - ExecutorService responseCallbackExecutor) { - this.okHttpRequest = okHttpRequest; - this.motherFactory = motherFactory; - this.converter = converter; - this.responseCallbackExecutor = responseCallbackExecutor; - - this.timeout = - new AsyncTimeout() { - @Override - protected void timedOut() { - CronetCall.this.cancel(); // Timeout has its own method named cancel - } - }; - timeout.timeout(motherFactory.callTimeoutMillis, MILLISECONDS); - } - - @Override - public Request request() { - return okHttpRequest; - } - - @Override - public Response execute() throws IOException { - evaluateExecutionPreconditions(); - try { - timeout.enter(); - RequestResponseConverter.CronetRequestAndOkHttpResponse requestAndOkHttpResponse = - converter.convert( - request(), motherFactory.readTimeoutMillis, motherFactory.writeTimeoutMillis); - convertedRequestAndResponse.set(requestAndOkHttpResponse); - - startRequestIfNotCanceled(); - - return toCronetCallFactoryResponse(this, requestAndOkHttpResponse.getResponse()); - } catch (RuntimeException | IOException e) { - // If the request finished successfully don't exit the timeout yet. Reading the body also - // needs to be considered and the body object will take care of exiting it. See - // toCronetCallFactoryResponse() for details. - timeout.exit(); - throw e; - } - } - - @Override - public void enqueue(Callback responseCallback) { - try { - timeout.enter(); - evaluateExecutionPreconditions(); - RequestResponseConverter.CronetRequestAndOkHttpResponse requestAndOkHttpResponse = - converter.convert( - request(), motherFactory.readTimeoutMillis, motherFactory.writeTimeoutMillis); - convertedRequestAndResponse.set(requestAndOkHttpResponse); - CronetCall call = this; - - Futures.addCallback( - requestAndOkHttpResponse.getResponseAsync(), - new FutureCallback() { - @Override - public void onSuccess(Response result) { - try { - responseCallback.onResponse(call, toCronetCallFactoryResponse(call, result)); - } catch (IOException e) { - // The call factory doesn't really mind this - the application code - // threw an exception while handling the response, they should have taken care - // of it. Just logging the error is consistent with plain OkHttp implementation. - Log.i(TAG, "Callback failure for " + toLoggableString(), e); - } - } - - @Override - public void onFailure(Throwable t) { - if (t instanceof IOException) { - responseCallback.onFailure(call, (IOException) t); - } else { - responseCallback.onFailure(call, new IOException(t)); - } - } - }, - responseCallbackExecutor); - - startRequestIfNotCanceled(); - } catch (IOException e) { - // If the request finished successfully don't exit the timeout yet. Reading the body also - // needs to be considered and the body object will take care of exiting it. See - // toCronetCallFactoryResponse() for details. - timeout.exit(); - responseCallback.onFailure(this, e); - } - } - - @Override - public Call clone() { - return motherFactory.newCall(request()); - } - - @Override - public void cancel() { - if (canceled.getAndSet(true)) { - // already canceled - return; - } - RequestResponseConverter.CronetRequestAndOkHttpResponse localConverted = convertedRequestAndResponse.get(); - if (localConverted != null) { - localConverted.getRequest().cancel(); - } // else the cancel signal will be picked up by the execute() / enqueue() methods. - } - - @Override - public boolean isExecuted() { - return executed.get(); - } - - @Override - public boolean isCanceled() { - return canceled.get(); - } - - @Override - public Timeout timeout() { - return timeout; - } - - private String toLoggableString() { - return "call to " + request().url().redact(); - } - - /** - * Verifies that the call can be executed and sets the state of the call to "being executed". - * - * @throws IllegalStateException if the request has already been executed. - * @throws IOException if the request was canceled - */ - private void evaluateExecutionPreconditions() throws IOException { - if (canceled.get()) { - throw new IOException("Can't execute canceled requests"); - } - checkState(!executed.getAndSet(true), "Already Executed"); - } - - private void startRequestIfNotCanceled() { - RequestResponseConverter.CronetRequestAndOkHttpResponse requestAndOkHttpResponse = convertedRequestAndResponse.get(); - checkState(requestAndOkHttpResponse != null, "convertedRequestAndResponse must be set!"); - - // There might be a race between the execution and cancellation - // evaluateExecutionPreconditions check didn't capture and cancel() might have missed that - // as well. Check once again that the request isn't canceled. - // This way, no matter how the instructions of the two threads are interleaved, we always end - // up with a serialized-like outcome (either cancel() was fully run before execute(), or vice - // versa). - - // Thread 1 (cancel() call) | Thread 2 (execute() call) - // ------------------------------------------------------------------------------- - // canceled = true | if (canceled) throw; - // convertedRequest?.cancel() | convertedRequest = convert(request) - // | if (canceled) convertedRequest.cancel() - if (canceled.get()) { - requestAndOkHttpResponse.getRequest().cancel(); - } else { - requestAndOkHttpResponse.getRequest().start(); - } - } - } - - private static Response toCronetCallFactoryResponse(CronetCall call, Response response) { - assert response.body() != null; - - return response - .newBuilder() - .body( - new CronetTransportResponseBody(response.body()) { - @Override - void customCloseHook() { - call.timeout.exit(); - } - }) - .build(); - } - - public static final class Builder - extends RequestResponseConverterBasedBuilder { - private static final int DEFAULT_READ_WRITE_TIMEOUT_MILLIS = 10000; - - private int readTimeoutMillis = DEFAULT_READ_WRITE_TIMEOUT_MILLIS; - private int writeTimeoutMillis = DEFAULT_READ_WRITE_TIMEOUT_MILLIS; - private int callTimeoutMillis = 0; // No timeout - private ExecutorService callbackExecutorService = null; - - Builder(CronetEngine cronetEngine) { - super(cronetEngine, CronetCallFactory.Builder.class); - } - - public Builder setReadTimeoutMillis(int readTimeoutMillis) { - checkArgument(readTimeoutMillis >= 0, "Read timeout mustn't be negative!"); - this.readTimeoutMillis = readTimeoutMillis; - return this; - } - - public Builder setWriteTimeoutMillis(int writeTimeoutMillis) { - checkArgument(writeTimeoutMillis >= 0, "Write timeout mustn't be negative!"); - this.writeTimeoutMillis = writeTimeoutMillis; - return this; - } - - public Builder setCallbackExecutorService(ExecutorService callbackExecutorService) { - checkNotNull(callbackExecutorService); - this.callbackExecutorService = callbackExecutorService; - return this; - } - - public Builder setCallTimeoutMillis(int callTimeoutMillis) { - checkArgument(callTimeoutMillis >= 0, "Call timeout mustn't be negative!"); - this.callTimeoutMillis = callTimeoutMillis; - - return this; - } - - @Override - CronetCallFactory build(RequestResponseConverter converter) { - ExecutorService localCallbackExecutorService; - if (callbackExecutorService == null) { - // Consistent with OkHttp impl - localCallbackExecutorService = Executors.newCachedThreadPool(); - } else { - localCallbackExecutorService = callbackExecutorService; - } - - return new CronetCallFactory( - converter, - localCallbackExecutorService, - readTimeoutMillis, - writeTimeoutMillis, - callTimeoutMillis); - } - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetInterceptor.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetInterceptor.java deleted file mode 100644 index a7cc7ecea..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetInterceptor.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import static com.google.firebase.components.Preconditions.checkNotNull; -import static java.util.concurrent.TimeUnit.MILLISECONDS; - -import android.util.Log; -import java.io.IOException; -import java.util.Iterator; -import java.util.Map; -import java.util.Map.Entry; -import java.util.concurrent.CancellationException; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import okhttp3.Call; -import okhttp3.Interceptor; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import org.chromium.net.CronetEngine; -import org.chromium.net.UrlRequest; - -/** - * An OkHttp interceptor that redirects HTTP traffic to use Cronet instead of using the OkHttp - * network stack. - * - *

The interceptor should be used as the last application interceptor to ensure that all other - * interceptors are visited before sending the request on wire and after a response is returned. - * - *

The interceptor is a plug-and-play replacement for the OkHttp stack for the most part, - * however, there are some caveats to keep in mind: - * - *

    - *
  1. The entirety of OkHttp core is bypassed. This includes caching configuration and network - * interceptors. - *
  2. Some response fields are not being populated due to mismatches between Cronet's and - * OkHttp's architecture. TODO(danstahr): add a concrete list). - *
- */ -public final class CronetInterceptor implements Interceptor, AutoCloseable { - private static final String TAG = "CronetInterceptor"; - - private static final int CANCELLATION_CHECK_INTERVAL_MILLIS = 500; - - private final RequestResponseConverter converter; - private final Map activeCalls = new ConcurrentHashMap<>(); - private final ScheduledExecutorService scheduledExecutor = new ScheduledThreadPoolExecutor(1); - - private CronetInterceptor(RequestResponseConverter converter) { - this.converter = checkNotNull(converter); - - // TODO(danstahr): There's no other way to know if the call is canceled but polling - // (https://github.com/square/okhttp/issues/7164). - ScheduledFuture unusedFuture = - scheduledExecutor.scheduleAtFixedRate( - () -> { - Iterator> activeCallsIterator = - activeCalls.entrySet().iterator(); - - while (activeCallsIterator.hasNext()) { - try { - Entry activeCall = activeCallsIterator.next(); - if (activeCall.getKey().isCanceled()) { - activeCallsIterator.remove(); - activeCall.getValue().cancel(); - } - } catch (RuntimeException e) { - Log.w(TAG, "Unable to propagate cancellation status", e); - } - } - }, - CANCELLATION_CHECK_INTERVAL_MILLIS, - CANCELLATION_CHECK_INTERVAL_MILLIS, - MILLISECONDS); - } - - @Override - public Response intercept(Chain chain) throws IOException { - if (chain.call().isCanceled()) { - throw new CancellationException(); - } - - Request request = chain.request(); - - RequestResponseConverter.CronetRequestAndOkHttpResponse requestAndOkHttpResponse = - converter.convert(request, chain.readTimeoutMillis(), chain.writeTimeoutMillis()); - - activeCalls.put(chain.call(), requestAndOkHttpResponse.getRequest()); - - try { - requestAndOkHttpResponse.getRequest().start(); - return toInterceptorResponse(requestAndOkHttpResponse.getResponse(), chain.call()); - } catch (RuntimeException | IOException e) { - // If the response is retrieved successfully the caller is responsible for closing - // the response, which will remove it from the active calls map. - activeCalls.remove(chain.call()); - throw e; - } - } - - /** Creates a {@link CronetInterceptor} builder. */ - public static Builder newBuilder(CronetEngine cronetEngine) { - return new Builder(cronetEngine); - } - - @Override - public void close() { - scheduledExecutor.shutdown(); - } - - /** A builder for {@link CronetInterceptor}. */ - public static final class Builder - extends RequestResponseConverterBasedBuilder { - - Builder(CronetEngine cronetEngine) { - super(cronetEngine, Builder.class); - } - - /** Builds the interceptor. The same builder can be used to build multiple interceptors. */ - @Override - public CronetInterceptor build(RequestResponseConverter converter) { - return new CronetInterceptor(converter); - } - } - - private Response toInterceptorResponse(Response response, Call call) { - assert response.body() != null; - - if (response.body() instanceof CronetInterceptorResponseBody) { - return response; - } - - return response - .newBuilder() - .body(new CronetInterceptorResponseBody(response.body(), call)) - .build(); - } - - private class CronetInterceptorResponseBody extends CronetTransportResponseBody { - private final Call call; - - private CronetInterceptorResponseBody(ResponseBody delegate, Call call) { - super(delegate); - this.call = call; - } - - @Override - void customCloseHook() { - activeCalls.remove(call); - } - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetTimeoutException.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetTimeoutException.java deleted file mode 100644 index bbcb590d6..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetTimeoutException.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import java.io.IOException; - -public class CronetTimeoutException extends IOException {} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetTransportResponseBody.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetTransportResponseBody.java deleted file mode 100644 index 094f2b531..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/CronetTransportResponseBody.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import androidx.annotation.Nullable; -import okhttp3.MediaType; -import okhttp3.ResponseBody; -import okio.BufferedSource; - -abstract class CronetTransportResponseBody extends ResponseBody { - - private final ResponseBody delegate; - - protected CronetTransportResponseBody(ResponseBody delegate) { - this.delegate = delegate; - } - - @Nullable - @Override - public final MediaType contentType() { - return delegate.contentType(); - } - - @Override - public final long contentLength() { - return delegate.contentLength(); - } - - @Override - public final BufferedSource source() { - return delegate.source(); - } - - @Override - public final void close() { - delegate.close(); - customCloseHook(); - } - - abstract void customCloseHook(); -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/OkHttpBridgeRequestCallback.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/OkHttpBridgeRequestCallback.java deleted file mode 100644 index 8bf53da5c..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/OkHttpBridgeRequestCallback.java +++ /dev/null @@ -1,300 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import static com.google.android.gms.common.internal.Preconditions.checkArgument; -import static com.google.android.gms.common.internal.Preconditions.checkState; -import static java.util.concurrent.TimeUnit.MILLISECONDS; - -import android.util.Log; - -import androidx.annotation.Nullable; - -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.SettableFuture; -import java.io.IOException; -import java.net.ProtocolException; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.atomic.AtomicBoolean; -import okio.Buffer; -import okio.Source; -import okio.Timeout; -import org.chromium.net.CronetException; -import org.chromium.net.UrlRequest; -import org.chromium.net.UrlResponseInfo; - -/** - * An implementation of Cronet's callback. This is the heart of the bridge and deals with most of - * the async-sync paradigm translation. - * - *

Translating the UrlResponseInfo is relatively straightforward as the entire object is - * available immediately and is relatively small, so it can easily fit in memory. - * - *

Translating the body is a bit more tricky because of the mismatch between OkHttp and Cronet - * designs. We invoke Cronet's read and wait for the result using synchronization primitives (see - * BodySource implementation). The implementation is assuming that there's always at most one read() - * request in flight (which is safe to assume), and relies on reasonable fairness of thread - * scheduling, especially when handling cancellations. - */ -class OkHttpBridgeRequestCallback extends UrlRequest.Callback { - - /** - * The byte buffer capacity for reading Cronet response bodies. Each response callback will - * allocate its own buffer of this size once the response starts being processed. - */ - private static final int CRONET_BYTE_BUFFER_CAPACITY = 32 * 1024; - - /** A bridge between Cronet's asynchronous callbacks and OkHttp's blocking stream-like reads. */ - private final SettableFuture bodySourceFuture = SettableFuture.create(); - - /** Signal whether the request is finished and the response has been fully read. */ - private final AtomicBoolean finished = new AtomicBoolean(false); - - /** Signal whether the request was canceled. */ - private final AtomicBoolean canceled = new AtomicBoolean(false); - - /** - * An internal, blocking, thread safe way of passing data between the callback methods and {@link - * #bodySourceFuture}. - * - *

Has a capacity of 2 - at most one slot for a read result and at most 1 slot for cancellation - * signal, this guarantees that all inserts are non blocking. - */ - private final BlockingQueue callbackResults = new ArrayBlockingQueue<>(2); - - /** The response headers. */ - private final SettableFuture headersFuture = SettableFuture.create(); - - /** The read timeout as specified by OkHttp. * */ - private final long readTimeoutMillis; - - /** The previous responses as reported to {@link #onRedirectReceived}, from oldest to newest. * */ - private final List urlResponseInfoChain = new ArrayList<>(); - - private final RedirectStrategy redirectStrategy; - - /** The request being processed. Set when the request is first seen by the callback. */ - private volatile UrlRequest request; - - OkHttpBridgeRequestCallback(long readTimeoutMillis, RedirectStrategy redirectStrategy) { - checkArgument(readTimeoutMillis >= 0); - - // So that we don't have to special case infinity. Int.MAX_VALUE is ~infinity for all practical - // use cases. - if (readTimeoutMillis == 0) { - this.readTimeoutMillis = Integer.MAX_VALUE; - } else { - this.readTimeoutMillis = readTimeoutMillis; - } - this.redirectStrategy = redirectStrategy; - } - - /** Returns the {@link UrlResponseInfo} for the request associated with this callback. */ - ListenableFuture getUrlResponseInfo() { - return headersFuture; - } - - /** - * Returns the OkHttp {@link Source} for the request associated with this callback. - * - *

Note that retrieving data from the {@code Source} instance might block further as the - * response body is streamed. - */ - ListenableFuture getBodySource() { - return bodySourceFuture; - } - - List getUrlResponseInfoChain() { - return Collections.unmodifiableList(urlResponseInfoChain); - } - - @Override - public void onRedirectReceived( - UrlRequest urlRequest, UrlResponseInfo urlResponseInfo, String nextUrl) { - // We shouldn't follow redirects - pass the given UrlResponseInfo as the ultimate result - if (!redirectStrategy.followRedirects()) { - checkState(headersFuture.set(urlResponseInfo)); - // Note: This might not match the content length headers but we have no way of accessing - // the actual body with current Cronet's APIs (see RedirectStrategy). - checkState(bodySourceFuture.set(new Buffer())); - urlRequest.cancel(); - return; - } - - // We should follow redirects and we haven't hit the cap yet - urlResponseInfoChain.add(urlResponseInfo); - if (urlResponseInfo.getUrlChain().size() <= redirectStrategy.numberOfRedirectsToFollow()) { - urlRequest.followRedirect(); - return; - } - - // Cap reached - cancel the request and fail. Exception crafted to match OkHttp. - urlRequest.cancel(); - - IOException e = - new ProtocolException( - "Too many follow-up requests: " + (redirectStrategy.numberOfRedirectsToFollow() + 1)); - headersFuture.setException(e); - bodySourceFuture.setException(e); - } - - @Override - public void onResponseStarted(UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) { - request = urlRequest; - - checkState(headersFuture.set(urlResponseInfo)); - checkState(bodySourceFuture.set(new CronetBodySource())); - } - - @Override - public void onReadCompleted( - UrlRequest urlRequest, UrlResponseInfo urlResponseInfo, ByteBuffer byteBuffer) { - callbackResults.add(new CallbackResult(CallbackStep.ON_READ_COMPLETED, byteBuffer, null)); - } - - @Override - public void onSucceeded(UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) { - callbackResults.add(new CallbackResult(CallbackStep.ON_SUCCESS, null, null)); - } - - @Override - public void onFailed(UrlRequest urlRequest, UrlResponseInfo urlResponseInfo, CronetException e) { - Log.e("CronetLog", "onFailed: ", e); - // If this was called before we start reading the body, the exception will - // propagate in the future providing headers and the body wrapper. - if (headersFuture.setException(e) && bodySourceFuture.setException(e)) { - return; - } - - // If this was called as a reaction to a read() call, the read result will propagate - // the exception. - callbackResults.add(new CallbackResult(CallbackStep.ON_FAILED, null, e)); - } - - @Override - public void onCanceled(UrlRequest urlRequest, UrlResponseInfo responseInfo) { - canceled.set(true); - callbackResults.add(new CallbackResult(CallbackStep.ON_CANCELED, null, null)); - - // If there's nobody listening it's possible that the cancellation happened before we even - // received anything from the server. In that case inform the thread that's awaiting server - // response about the cancellation as well. This becomes a no-op if the futures - // were already set. - IOException e = new IOException("The request was canceled!"); - headersFuture.setException(e); - bodySourceFuture.setException(e); - } - - private class CronetBodySource implements Source { - - private ByteBuffer buffer = ByteBuffer.allocateDirect(CRONET_BYTE_BUFFER_CAPACITY); - - /** Whether the close() method has been called. */ - private volatile boolean closed = false; - - @Override - public long read(Buffer sink, long byteCount) throws IOException { - if (canceled.get()) { - throw new IOException("The request was canceled!"); - } - - // Using IAE instead of NPE (checkNotNull) for okio.RealBufferedSource consistency - checkArgument(sink != null, "sink == null"); - checkArgument(byteCount >= 0, "byteCount < 0: %s", byteCount); - checkState(!closed, "closed"); - - if (finished.get()) { - return -1; - } - - if (byteCount < buffer.limit()) { - buffer.limit((int) byteCount); - } - - request.read(buffer); - - CallbackResult result; - try { - result = callbackResults.poll(readTimeoutMillis, MILLISECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - result = null; - } - - if (result == null) { - // Either readResult.poll() was interrupted or it timed out. - request.cancel(); - throw new CronetTimeoutException(); - } - - switch (result.callbackStep) { - // We null the buffer in final statuses to allow fast GC of the buffer even if the callback - // is still in use. - case ON_FAILED: - finished.set(true); - buffer = null; - throw new IOException(result.exception); - case ON_SUCCESS: - finished.set(true); - buffer = null; - return -1; - case ON_CANCELED: - // The canceled flag is already set by the onCanceled method - // so not setting it here. - - buffer = null; - throw new IOException("The request was canceled!"); - case ON_READ_COMPLETED: - result.buffer.flip(); - int bytesWritten = sink.write(result.buffer); - result.buffer.clear(); - return bytesWritten; - } - - throw new AssertionError("The switch block above is exhaustive!"); - } - - @Override - public Timeout timeout() { - // TODO(danstahr): This should likely respect the OkHttp timeout somehow - return Timeout.NONE; - } - - @Override - public void close() { - if (closed) { - return; - } - closed = true; - if (!finished.get()) { - request.cancel(); - } - } - } - - private static class CallbackResult { - private final CallbackStep callbackStep; - @Nullable - private final ByteBuffer buffer; - @Nullable private final CronetException exception; - - private CallbackResult( - CallbackStep callbackStep, - @Nullable ByteBuffer buffer, - @Nullable CronetException exception) { - this.callbackStep = callbackStep; - this.buffer = buffer; - this.exception = exception; - } - } - - private enum CallbackStep { - ON_READ_COMPLETED, - ON_SUCCESS, - ON_FAILED, - ON_CANCELED - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RedirectStrategy.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RedirectStrategy.java deleted file mode 100644 index a79e842ad..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RedirectStrategy.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -/** Defines a redirect strategy for the Cronet OkHttp transport layer. */ -public abstract class RedirectStrategy { - - /** The default number of redirects to follow. Should be less than the Chromium wide safeguard. */ - private static final int DEFAULT_REDIRECTS = 16; - - /** - * Returns whether redirects should be followed at all. If set to false, the redirect response - * will be returned. - */ - abstract boolean followRedirects(); - - /** - * Returns the maximum number of redirects to follow. If more redirects are attempted an exception - * should be thrown by the component handling the request. Shouldn't be called at all if {@link - * #followRedirects()} return false. - */ - abstract int numberOfRedirectsToFollow(); - - /** - * Returns a strategy which will not follow redirects. - * - *

Note that because of Cronet's limitations - * (https://developer.android.com/guide/topics/connectivity/cronet/lifecycle#overview) it is - * impossible to retrieve the body of a redirect response. As a result, a dummy empty body will - * always be provided. - */ - public static RedirectStrategy withoutRedirects() { - return WithoutRedirectsHolder.INSTANCE; - } - - /** - * Returns a strategy which will follow redirects up to {@link #DEFAULT_REDIRECTS} times. If more - * redirects are attempted an exception is thrown. - */ - public static RedirectStrategy defaultStrategy() { - return DefaultRedirectsHolder.INSTANCE; - } - - private static class WithoutRedirectsHolder { - private static final RedirectStrategy INSTANCE = - new RedirectStrategy() { - @Override - boolean followRedirects() { - return false; - } - - @Override - int numberOfRedirectsToFollow() { - throw new UnsupportedOperationException(); - } - }; - } - - private static class DefaultRedirectsHolder { - private static final RedirectStrategy INSTANCE = - new RedirectStrategy() { - @Override - boolean followRedirects() { - return true; - } - - @Override - int numberOfRedirectsToFollow() { - return DEFAULT_REDIRECTS; - } - }; - } - - private RedirectStrategy() {} -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestBodyConverter.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestBodyConverter.java deleted file mode 100644 index cbac69bd5..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestBodyConverter.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import java.io.IOException; -import okhttp3.RequestBody; -import org.chromium.net.UploadDataProvider; - -/** An interface for classes converting from OkHttp to Cronet request bodies. */ -interface RequestBodyConverter { - UploadDataProvider convertRequestBody(RequestBody requestBody, int writeTimeoutMillis) - throws IOException; -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestBodyConverterImpl.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestBodyConverterImpl.java deleted file mode 100644 index 89babfe9d..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestBodyConverterImpl.java +++ /dev/null @@ -1,319 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import static java.util.concurrent.TimeUnit.MILLISECONDS; - -import androidx.annotation.VisibleForTesting; -import com.google.common.base.Verify; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.Uninterruptibles; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.TimeoutException; -import okhttp3.RequestBody; -import okio.Buffer; -import okio.BufferedSink; -import okio.Okio; -import org.chromium.net.UploadDataProvider; -import org.chromium.net.UploadDataSink; - -final class RequestBodyConverterImpl implements RequestBodyConverter { - - private static final long IN_MEMORY_BODY_LENGTH_THRESHOLD_BYTES = 1024 * 1024; - - private final InMemoryRequestBodyConverter inMemoryRequestBodyConverter; - private final StreamingRequestBodyConverter streamingRequestBodyConverter; - - RequestBodyConverterImpl( - InMemoryRequestBodyConverter inMemoryConverter, - StreamingRequestBodyConverter streamingConverter) { - this.inMemoryRequestBodyConverter = inMemoryConverter; - this.streamingRequestBodyConverter = streamingConverter; - } - - static RequestBodyConverterImpl create(ExecutorService bodyReaderExecutor) { - return new RequestBodyConverterImpl( - new InMemoryRequestBodyConverter(), new StreamingRequestBodyConverter(bodyReaderExecutor)); - } - - @Override - public UploadDataProvider convertRequestBody(RequestBody requestBody, int writeTimeoutMillis) - throws IOException { - long contentLength = requestBody.contentLength(); - if (contentLength == -1 || contentLength > IN_MEMORY_BODY_LENGTH_THRESHOLD_BYTES) { - return streamingRequestBodyConverter.convertRequestBody(requestBody, writeTimeoutMillis); - } else { - return inMemoryRequestBodyConverter.convertRequestBody(requestBody, writeTimeoutMillis); - } - } - - /** - * Implementation of {@link RequestBodyConverter} that doesn't need to hold the entire request - * body in memory. - * - *

- * - *

    - *
  1. {@link RequestBody#writeTo(BufferedSink)} is invoked on the body, but the sink doesn't - * accept any data - *
  2. A call to {@link UploadDataProvider#read(UploadDataSink, ByteBuffer)} unblocks the sink, - * which accepts a part of the body (size depends on the buffer's capacity), then blocks - * again. Buffer is sent to Cronet. - *
- * - * This is repeated until the entire body has been read. - */ - @VisibleForTesting - static final class StreamingRequestBodyConverter implements RequestBodyConverter { - - private final ExecutorService readerExecutor; - - StreamingRequestBodyConverter(ExecutorService readerExecutor) { - this.readerExecutor = readerExecutor; - } - - @Override - public UploadDataProvider convertRequestBody(RequestBody requestBody, int writeTimeoutMillis) { - return new StreamingUploadDataProvider( - requestBody, new UploadBodyDataBroker(), readerExecutor, writeTimeoutMillis); - } - - private static class StreamingUploadDataProvider extends UploadDataProvider { - private final RequestBody okHttpRequestBody; - private final UploadBodyDataBroker broker; - private final ListeningExecutorService readTaskExecutor; - private final long writeTimeoutMillis; - - /** The future for the task that reads the OkHttp request body in the background. */ - private ListenableFuture readTaskFuture; - - /** The number of bytes we read from the OkHttp body thus far. */ - private long totalBytesReadFromOkHttp; - - private StreamingUploadDataProvider( - RequestBody okHttpRequestBody, - UploadBodyDataBroker broker, - ExecutorService readTaskExecutor, - long writeTimeoutMillis) { - this.okHttpRequestBody = okHttpRequestBody; - this.broker = broker; - if (readTaskExecutor instanceof ListeningExecutorService) { - this.readTaskExecutor = (ListeningExecutorService) readTaskExecutor; - } else { - this.readTaskExecutor = MoreExecutors.listeningDecorator(readTaskExecutor); - } - - // So that we don't have to special case infinity. Int.MAX_VALUE is ~infinity for all - // practical use cases. - this.writeTimeoutMillis = writeTimeoutMillis == 0 ? Integer.MAX_VALUE : writeTimeoutMillis; - } - - @Override - public long getLength() throws IOException { - return okHttpRequestBody.contentLength(); - } - - @Override - public void read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) throws IOException { - ensureReadTaskStarted(); - - if (getLength() == -1) { - readUnknownBodyLength(uploadDataSink, byteBuffer); - } else { - readKnownBodyLength(uploadDataSink, byteBuffer); - } - } - - private void readKnownBodyLength(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) - throws IOException { - try { - UploadBodyDataBroker.ReadResult readResult = readFromOkHttp(byteBuffer); - - if (totalBytesReadFromOkHttp > getLength()) { - throw prepareBodyTooLongException(getLength(), totalBytesReadFromOkHttp); - } - - if (totalBytesReadFromOkHttp < getLength()) { - switch (readResult) { - case SUCCESS: - uploadDataSink.onReadSucceeded(false); - break; - case END_OF_BODY: - throw new IOException("The source has been exhausted but we expected more data!"); - } - return; - } - // Else we're handling what's supposed to be the last chunk - handleLastBodyRead(uploadDataSink, byteBuffer); - - } catch (TimeoutException | ExecutionException e) { - readTaskFuture.cancel(true); - uploadDataSink.onReadError(new IOException(e)); - } - } - - /** - * The last body read is special for fixed length bodies - if Cronet receives exactly the - * right amount of data it won't ask for more, even if there is more data in the stream. As a - * result, when we read the advertised number of bytes, we need to make sure that the stream - * is indeed finished. - */ - private void handleLastBodyRead(UploadDataSink uploadDataSink, ByteBuffer filledByteBuffer) - throws IOException, TimeoutException, ExecutionException { - // We reuse the same buffer for the END_OF_DATA read (it should be non-destructive and if - // it overwrites what's in there we don't mind as that's an error anyway). We just need - // to make sure we restore the original position afterwards. We don't use mark() / reset() - // as the mark position can be invalidated by limit manipulation. - int bufferPosition = filledByteBuffer.position(); - filledByteBuffer.position(0); - - UploadBodyDataBroker.ReadResult readResult = readFromOkHttp(filledByteBuffer); - - if (!readResult.equals(UploadBodyDataBroker.ReadResult.END_OF_BODY)) { - throw prepareBodyTooLongException(getLength(), totalBytesReadFromOkHttp); - } - - Verify.verify( - filledByteBuffer.position() == 0, - "END_OF_BODY reads shouldn't write anything to the buffer"); - - // revert the position change - filledByteBuffer.position(bufferPosition); - - uploadDataSink.onReadSucceeded(false); - } - - private void readUnknownBodyLength(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) { - try { - UploadBodyDataBroker.ReadResult readResult = readFromOkHttp(byteBuffer); - uploadDataSink.onReadSucceeded(readResult.equals(UploadBodyDataBroker.ReadResult.END_OF_BODY)); - } catch (TimeoutException | ExecutionException e) { - readTaskFuture.cancel(true); - uploadDataSink.onReadError(new IOException(e)); - } - } - - private void ensureReadTaskStarted() { - // We don't expect concurrent calls so a simple flag is sufficient - if (readTaskFuture == null) { - readTaskFuture = - readTaskExecutor.submit( - (Callable) - () -> { - BufferedSink bufferedSink = Okio.buffer(broker); - okHttpRequestBody.writeTo(bufferedSink); - bufferedSink.flush(); - broker.handleEndOfStreamSignal(); - return null; - }); - - Futures.addCallback( - readTaskFuture, - new FutureCallback() { - @Override - public void onSuccess(Object result) {} - - @Override - public void onFailure(Throwable t) { - broker.setBackgroundReadError(t); - } - }, - MoreExecutors.directExecutor()); - } - } - - private UploadBodyDataBroker.ReadResult readFromOkHttp(ByteBuffer byteBuffer) - throws TimeoutException, ExecutionException { - int positionBeforeRead = byteBuffer.position(); - UploadBodyDataBroker.ReadResult readResult = - Uninterruptibles.getUninterruptibly( - broker.enqueueBodyRead(byteBuffer), writeTimeoutMillis, MILLISECONDS); - int bytesRead = byteBuffer.position() - positionBeforeRead; - totalBytesReadFromOkHttp += bytesRead; - return readResult; - } - - private static IOException prepareBodyTooLongException( - long expectedLength, long minActualLength) { - return new IOException( - "Expected " + expectedLength + " bytes but got at least " + minActualLength); - } - - @Override - public void rewind(UploadDataSink uploadDataSink) { - // TODO(danstahr): OkHttp 4 can use isOneShot flag here and rewind safely. - uploadDataSink.onRewindError(new UnsupportedOperationException("Rewind is not supported!")); - } - } - } - - /** - * Converts OkHttp's {@link RequestBody} to Cronet's {@link UploadDataProvider} by materializing - * the body in memory first. - * - *

This strategy shouldn't be used for large requests (and for requests with uncapped length) - * to avoid OOM issues. - */ - @VisibleForTesting - static final class InMemoryRequestBodyConverter implements RequestBodyConverter { - - @Override - public UploadDataProvider convertRequestBody(RequestBody requestBody, int writeTimeoutMillis) - throws IOException { - - // content length is immutable by contract - long length = requestBody.contentLength(); - if (length < 0 || length > IN_MEMORY_BODY_LENGTH_THRESHOLD_BYTES) { - throw new IOException( - "Expected definite length less than " - + IN_MEMORY_BODY_LENGTH_THRESHOLD_BYTES - + "but got " - + length); - } - - return new UploadDataProvider() { - private volatile boolean isMaterialized = false; - private final Buffer materializedBody = new Buffer(); - - @Override - public long getLength() { - return length; - } - - @Override - public void read(UploadDataSink uploadDataSink, ByteBuffer byteBuffer) throws IOException { - // We're not expecting any concurrent calls here so a simple flag should be sufficient. - if (!isMaterialized) { - requestBody.writeTo(materializedBody); - materializedBody.flush(); - isMaterialized = true; - long reportedLength = getLength(); - long actualLength = materializedBody.size(); - if (actualLength != reportedLength) { - throw new IOException( - "Expected " + reportedLength + " bytes but got " + actualLength); - } - } - if (materializedBody.read(byteBuffer) == -1) { - // This should never happen - for known body length we shouldn't be called at all - // if there's no more data to read. - throw new IllegalStateException("The source has been exhausted but we expected more!"); - } - uploadDataSink.onReadSucceeded(false); - } - - @Override - public void rewind(UploadDataSink uploadDataSink) { - // TODO(danstahr): OkHttp 4 can use isOneShot flag here and rewind safely. - uploadDataSink.onRewindError(new UnsupportedOperationException()); - } - }; - } - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestResponseConverter.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestResponseConverter.java deleted file mode 100644 index e53617ca8..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestResponseConverter.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.MoreExecutors; -import java.io.IOException; -import java.util.concurrent.Executor; -import java.util.concurrent.Future; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import org.chromium.net.CronetEngine; -import org.chromium.net.UrlRequest; - -/** Converts OkHttp requests to Cronet requests. */ -final class RequestResponseConverter { - private static final String CONTENT_LENGTH_HEADER_NAME = "Content-Length"; - private static final String CONTENT_TYPE_HEADER_NAME = "Content-Type"; - private static final String CONTENT_TYPE_HEADER_DEFAULT_VALUE = "application/octet-stream"; - - private final CronetEngine cronetEngine; - private final Executor uploadDataProviderExecutor; - private final ResponseConverter responseConverter; - private final RequestBodyConverter requestBodyConverter; - private final RedirectStrategy redirectStrategy; - - RequestResponseConverter( - CronetEngine cronetEngine, - Executor uploadDataProviderExecutor, - RequestBodyConverter requestBodyConverter, - ResponseConverter responseConverter, - RedirectStrategy redirectStrategy) { - this.cronetEngine = cronetEngine; - this.uploadDataProviderExecutor = uploadDataProviderExecutor; - this.requestBodyConverter = requestBodyConverter; - this.responseConverter = responseConverter; - this.redirectStrategy = redirectStrategy; - } - - /** - * Converts OkHttp's {@link Request} to a corresponding Cronet's {@link UrlRequest}. - * - *

Since Cronet doesn't have a notion of a Response, which is handled entirely from the - * callbacks, this method also returns a {@link java.util.concurrent.Future} like object the - * caller should use to obtain the matching {@link Response} for the given request. For example: - * - *

-     *   RequestResponseConverter converter = ...
-     *   CronetRequestAndOkHttpResponse reqResp = converter.convert(okHttpRequest);
-     *   reqResp.getRequest.start();
-     *
-     *   // Will block until status code, headers... are available
-     *   Response okHttpResponse = reqResp.getResponse();
-     *
-     *   // use OkHttp Response as usual
-     * 
- */ - CronetRequestAndOkHttpResponse convert( - Request okHttpRequest, int readTimeoutMillis, int writeTimeoutMillis) throws IOException { - - OkHttpBridgeRequestCallback callback = - new OkHttpBridgeRequestCallback(readTimeoutMillis, redirectStrategy); - - // The OkHttp request callback methods are lightweight, the heavy lifting is done by OkHttp / - // app owned threads. Use a direct executor to avoid extra thread hops. - UrlRequest.Builder builder = - cronetEngine - .newUrlRequestBuilder( - okHttpRequest.url().toString(), callback, MoreExecutors.directExecutor()) - .allowDirectExecutor(); - - builder.setHttpMethod(okHttpRequest.method()); - - for (int i = 0; i < okHttpRequest.headers().size(); i++) { - builder.addHeader(okHttpRequest.headers().name(i), okHttpRequest.headers().value(i)); - } - - RequestBody body = okHttpRequest.body(); - - if (body != null) { - if (okHttpRequest.header(CONTENT_LENGTH_HEADER_NAME) == null && body.contentLength() != -1) { - builder.addHeader(CONTENT_LENGTH_HEADER_NAME, String.valueOf(body.contentLength())); - } - - if (body.contentLength() != 0) { - if (body.contentType() != null) { - builder.addHeader(CONTENT_TYPE_HEADER_NAME, body.contentType().toString()); - } else if (okHttpRequest.header(CONTENT_TYPE_HEADER_NAME) == null) { - // Cronet always requires content-type to be present when a body is present. Use a generic - // value if one isn't provided. - builder.addHeader(CONTENT_TYPE_HEADER_NAME, CONTENT_TYPE_HEADER_DEFAULT_VALUE); - } // else use the header - - builder.setUploadDataProvider( - requestBodyConverter.convertRequestBody(body, writeTimeoutMillis), - uploadDataProviderExecutor); - } - } - - return new CronetRequestAndOkHttpResponse( - builder.build(), createResponseSupplier(okHttpRequest, callback)); - } - - private ResponseSupplier createResponseSupplier( - Request request, OkHttpBridgeRequestCallback callback) { - return new ResponseSupplier() { - @Override - public Response getResponse() throws IOException { - return responseConverter.toResponse(request, callback); - } - - @Override - public ListenableFuture getResponseFuture() { - return responseConverter.toResponseAsync(request, callback); - } - }; - } - - /** A {@link Future} like holder for OkHttp's {@link Response}. */ - private interface ResponseSupplier { - Response getResponse() throws IOException; - - ListenableFuture getResponseFuture(); - } - - /** A simple data class for bundling Cronet request and OkHttp response. */ - static final class CronetRequestAndOkHttpResponse { - private final UrlRequest request; - private final ResponseSupplier responseSupplier; - - CronetRequestAndOkHttpResponse(UrlRequest request, ResponseSupplier responseSupplier) { - this.request = request; - this.responseSupplier = responseSupplier; - } - - public UrlRequest getRequest() { - return request; - } - - public Response getResponse() throws IOException { - return responseSupplier.getResponse(); - } - - public ListenableFuture getResponseAsync() { - return responseSupplier.getResponseFuture(); - } - } -} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestResponseConverterBasedBuilder.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestResponseConverterBasedBuilder.java deleted file mode 100644 index fbdaccf3f..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/RequestResponseConverterBasedBuilder.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.tonapps.wallet.api.cronet; - - -import static com.google.android.gms.common.internal.Preconditions.checkArgument; -import static com.google.android.gms.common.internal.Preconditions.checkNotNull; - -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import org.chromium.net.CronetEngine; -import org.chromium.net.UploadDataProvider; - -abstract class RequestResponseConverterBasedBuilder< - SubBuilderT extends RequestResponseConverterBasedBuilder, - ObjectBeingBuiltT> { - private static final int DEFAULT_THREAD_POOL_SIZE = 4; - - private final CronetEngine cronetEngine; - private int uploadDataProviderExecutorSize = DEFAULT_THREAD_POOL_SIZE; - // Not setting the default straight away to lazy initialize the object if it ends up not being - // used. - private RedirectStrategy redirectStrategy = null; - private final SubBuilderT castedThis; - - @SuppressWarnings("unchecked") // checked as a precondition - RequestResponseConverterBasedBuilder(CronetEngine cronetEngine, Class clazz) { - this.cronetEngine = checkNotNull(cronetEngine); - checkArgument(this.getClass().equals(clazz)); - castedThis = (SubBuilderT) this; - } - - /** - * Sets the size of upload data provider executor. The same executor is used for all upload data - * providers within the interceptor. - * - * @see org.chromium.net.UrlRequest.Builder#setUploadDataProvider(UploadDataProvider, Executor) - */ - public final SubBuilderT setUploadDataProviderExecutorSize(int size) { - checkArgument(size > 0, "The number of threads must be positive!"); - uploadDataProviderExecutorSize = size; - return castedThis; - } - - /** - * Sets the strategy for following redirects. - * - *

Note that the Cronet (i.e. Chromium) wide safeguards will still apply if one attempts to - * follow redirects too many times. - */ - public final SubBuilderT setRedirectStrategy(RedirectStrategy redirectStrategy) { - checkNotNull(redirectStrategy); - this.redirectStrategy = redirectStrategy; - return castedThis; - } - - abstract ObjectBeingBuiltT build(RequestResponseConverter converter); - - public final ObjectBeingBuiltT build() { - if (redirectStrategy == null) { - redirectStrategy = RedirectStrategy.defaultStrategy(); - } - - RequestResponseConverter converter = - new RequestResponseConverter( - cronetEngine, - Executors.newFixedThreadPool(uploadDataProviderExecutorSize), - // There must always be enough executors to blocking-read the OkHttp request bodies - // otherwise deadlocks can occur. - RequestBodyConverterImpl.create(Executors.newCachedThreadPool()), - new ResponseConverter(), - redirectStrategy); - - return build(converter); - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/ResponseConverter.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/ResponseConverter.java deleted file mode 100644 index 42f26fb65..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/ResponseConverter.java +++ /dev/null @@ -1,249 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import static com.google.firebase.components.Preconditions.checkArgument; -import static com.google.firebase.components.Preconditions.checkNotNull; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.common.base.Ascii; -import com.google.common.base.Splitter; -import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Iterables; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.MoreExecutors; -import com.google.common.util.concurrent.Uninterruptibles; - -import java.io.IOException; -import java.net.ProtocolException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; -import okhttp3.MediaType; -import okhttp3.Protocol; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.ResponseBody; -import okio.Okio; -import okio.Source; -import org.chromium.net.UrlResponseInfo; - -/** - * Converts Cronet's responses (or, more precisely, its chunks as they come from Cronet's {@link - * org.chromium.net.UrlRequest.Callback}), to OkHttp's {@link Response}. - */ -final class ResponseConverter { - private static final String CONTENT_LENGTH_HEADER_NAME = "Content-Length"; - private static final String CONTENT_TYPE_HEADER_NAME = "Content-Type"; - private static final String CONTENT_ENCODING_HEADER_NAME = "Content-Encoding"; - - // https://source.chromium.org/search?q=symbol:FilterSourceStream::ParseEncodingType%20f:cc - private static final ImmutableSet ENCODINGS_HANDLED_BY_CRONET = - ImmutableSet.of("br", "deflate", "gzip", "x-gzip"); - - private static final Splitter COMMA_SPLITTER = Splitter.on(',').trimResults().omitEmptyStrings(); - - /** - * Creates an OkHttp's Response from the OkHttp-Cronet bridging callback. - * - *

As long as the callback's {@code UrlResponseInfo} is available this method is non-blocking. - * However, this method doesn't fetch the entire body response. As a result, subsequent calls to - * the result's {@link Response#body()} methods might block further. - */ - Response toResponse(Request request, OkHttpBridgeRequestCallback callback) throws IOException { - UrlResponseInfo cronetResponseInfo = getFutureValue(callback.getUrlResponseInfo()); - Response.Builder responseBuilder = - createResponse(request, cronetResponseInfo, getFutureValue(callback.getBodySource())); - - List redirectResponseInfos = callback.getUrlResponseInfoChain(); - List urlChain = cronetResponseInfo.getUrlChain(); - - if (!redirectResponseInfos.isEmpty()) { - checkArgument( - urlChain.size() == redirectResponseInfos.size() + 1, - "The number of redirects should be consistent across URLs and headers!"); - - Response priorResponse = null; - for (int i = 0; i < redirectResponseInfos.size(); i++) { - Request redirectedRequest = request.newBuilder().url(urlChain.get(i)).build(); - priorResponse = - createResponse(redirectedRequest, redirectResponseInfos.get(i), null) - .priorResponse(priorResponse) - .build(); - } - - responseBuilder - .request(request.newBuilder().url(Iterables.getLast(urlChain)).build()) - .priorResponse(priorResponse); - } - - return responseBuilder.build(); - } - - ListenableFuture toResponseAsync( - Request request, OkHttpBridgeRequestCallback callback) { - return Futures.whenAllComplete(callback.getUrlResponseInfo(), callback.getBodySource()) - .call(() -> toResponse(request, callback), MoreExecutors.directExecutor()); - } - - private static Response.Builder createResponse( - Request request, UrlResponseInfo cronetResponseInfo, @Nullable Source bodySource) - throws IOException { - - Response.Builder responseBuilder = new Response.Builder(); - - @Nullable String contentType = getLastHeaderValue(CONTENT_TYPE_HEADER_NAME, cronetResponseInfo); - - // If all content encodings are those known to Cronet natively, Cronet decodes the body stream. - // Otherwise, it's sent to the callbacks verbatim. For consistency with OkHttp, we only leave - // the Content-Encoding headers if Cronet didn't decode the request. Similarly, for consistency, - // we strip the Content-Length header of decoded responses. - - @Nullable String contentLengthString = null; - - // Theoretically, the content encodings can be scattered across multiple comma separated - // Content-Encoding headers. This list contains individual encodings. - List contentEncodingItems = new ArrayList<>(); - - for (String contentEncodingHeaderValue : - getOrDefault( - cronetResponseInfo.getAllHeaders(), - CONTENT_ENCODING_HEADER_NAME, - Collections.emptyList())) { - Iterables.addAll(contentEncodingItems, COMMA_SPLITTER.split(contentEncodingHeaderValue)); - } - - boolean keepEncodingAffectedHeaders = - contentEncodingItems.isEmpty() - || !ENCODINGS_HANDLED_BY_CRONET.containsAll(contentEncodingItems); - - if (keepEncodingAffectedHeaders) { - contentLengthString = getLastHeaderValue(CONTENT_LENGTH_HEADER_NAME, cronetResponseInfo); - } - - ResponseBody responseBody = null; - if (bodySource != null) { - responseBody = - createResponseBody( - request, - cronetResponseInfo.getHttpStatusCode(), - contentType, - contentLengthString, - bodySource); - } - - responseBuilder - .request(request) - .code(cronetResponseInfo.getHttpStatusCode()) - .message(cronetResponseInfo.getHttpStatusText()) - .protocol(convertProtocol(cronetResponseInfo.getNegotiatedProtocol())) - .body(responseBody); - - for (Map.Entry header : cronetResponseInfo.getAllHeadersAsList()) { - boolean copyHeader = true; - if (!keepEncodingAffectedHeaders) { - if (Ascii.equalsIgnoreCase(header.getKey(), CONTENT_LENGTH_HEADER_NAME) - || Ascii.equalsIgnoreCase(header.getKey(), CONTENT_ENCODING_HEADER_NAME)) { - copyHeader = false; - } - } - if (copyHeader) { - responseBuilder.addHeader(header.getKey(), header.getValue()); - } - } - - return responseBuilder; - } - - /** - * Creates an OkHttp's ResponseBody from the OkHttp-Cronet bridging callback. - * - *

As long as the callback's {@code UrlResponseInfo} is available this method is non-blocking. - * However, this method doesn't fetch the entire body response. As a result, subsequent calls to - * {@link ResponseBody} methods might block further to fetch parts of the body. - */ - private static ResponseBody createResponseBody( - Request request, - int httpStatusCode, - @Nullable String contentType, - @Nullable String contentLengthString, - Source bodySource) - throws IOException { - - long contentLength; - - // Ignore content-length header for HEAD requests (consistency with OkHttp) - if (request.method().equals("HEAD")) { - contentLength = 0; - } else { - try { - contentLength = contentLengthString != null ? Long.parseLong(contentLengthString) : -1; - } catch (NumberFormatException e) { - // TODO(danstahr): add logging - contentLength = -1; - } - } - - // Check for absence of body in No Content / Reset Content responses (OkHttp consistency) - if ((httpStatusCode == 204 || httpStatusCode == 205) && contentLength > 0) { - throw new ProtocolException( - "HTTP " + httpStatusCode + " had non-zero Content-Length: " + contentLengthString); - } - - return ResponseBody.create( - contentType != null ? MediaType.parse(contentType) : null, - contentLength, - Okio.buffer(bodySource)); - } - - /** Converts Cronet's negotiated protocol string to OkHttp's {@link Protocol}. */ - private static Protocol convertProtocol(String negotiatedProtocol) { - // See - // https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids - if (negotiatedProtocol.contains("quic")) { - return Protocol.QUIC; - } else if (negotiatedProtocol.contains("h3")) { - // TODO(danstahr): Should be h3 for newer OkHttp - return Protocol.QUIC; - } else if (negotiatedProtocol.contains("spdy")) { - return Protocol.HTTP_2; - } else if (negotiatedProtocol.contains("h2")) { - return Protocol.HTTP_2; - } else if (negotiatedProtocol.contains("http/1.1")) { - return Protocol.HTTP_1_1; - } - - return Protocol.HTTP_1_0; - } - - /** Returns the last header value for the given name, or null if the header isn't present. */ - @Nullable - private static String getLastHeaderValue(String name, UrlResponseInfo responseInfo) { - List headers = responseInfo.getAllHeaders().get(name); - if (headers == null || headers.isEmpty()) { - return null; - } - return Iterables.getLast(headers); - } - - private static T getFutureValue(Future future) throws IOException { - try { - return Uninterruptibles.getUninterruptibly(future); - } catch (ExecutionException e) { - throw new IOException(e); - } - } - - private static V getOrDefault(Map map, K key, @NonNull V defaultValue) { - V value = map.get(key); - if (value == null) { - return checkNotNull(defaultValue); - } else { - return value; - } - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/UploadBodyDataBroker.java b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/UploadBodyDataBroker.java deleted file mode 100644 index 6229071d4..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/cronet/UploadBodyDataBroker.java +++ /dev/null @@ -1,161 +0,0 @@ -package com.tonapps.wallet.api.cronet; - -import static com.google.android.gms.common.internal.Preconditions.checkState; - -import android.util.Pair; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.SettableFuture; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; -import okio.Buffer; -import okio.Sink; -import okio.Timeout; -import org.chromium.net.UploadDataSink; - -final class UploadBodyDataBroker implements Sink { - - /** - * The read request calls to {@link org.chromium.net.UploadDataProvider#read(UploadDataSink, - * ByteBuffer)} associated with this broker that we haven't started handling. - * - *

We don't expect more than one parallel read call for a single request body provider. - */ - private final BlockingQueue>> pendingRead = - new ArrayBlockingQueue<>(1); - - /** - * Whether the sink has been closed. - * - *

Calling close() has no practical use but we check that nobody tries to write to the sink - * after closing it, which is an indication of misuse. - */ - private final AtomicBoolean isClosed = new AtomicBoolean(); - - /** - * The exception thrown by the body reading background thread, if any. The exception will be - * rethrown every time someone attempts to continue reading the body. - */ - private final AtomicReference backgroundReadThrowable = new AtomicReference<>(); - - /** - * Indicates that Cronet is ready to receive another body part. - * - *

This method is executed by Cronet's upload data provider. - */ - Future enqueueBodyRead(ByteBuffer readBuffer) { - Throwable backgroundThrowable = backgroundReadThrowable.get(); - if (backgroundThrowable != null) { - return Futures.immediateFailedFuture(backgroundThrowable); - } - SettableFuture future = SettableFuture.create(); - pendingRead.add(Pair.create(readBuffer, future)); - - // Properly handle interleaving handleBackgroundReadError / enqueueBodyRead calls. - if ((backgroundThrowable = backgroundReadThrowable.get()) != null) { - future.setException(backgroundThrowable); - } - return future; - } - - /** - * Signals that reading the OkHttp body failed with the given throwable. - * - *

This method is executed by the background OkHttp body reading thread. - */ - void setBackgroundReadError(Throwable t) { - backgroundReadThrowable.set(t); - Pair> read = pendingRead.poll(); - if (read != null) { - read.second.setException(t); - } - } - - /** - * Signals that reading the body has ended and no future bytes will be sent. - * - *

This method is executed by the background OkHttp body reading thread. - */ - void handleEndOfStreamSignal() throws IOException { - if (isClosed.getAndSet(true)) { - throw new IllegalStateException("Already closed"); - } - - getPendingCronetRead().second.set(ReadResult.END_OF_BODY); - } - - /** - * {@inheritDoc} - * - *

This method is executed by the background OkHttp body reading thread. - */ - @Override - public void write(Buffer source, long byteCount) throws IOException { - // This is just a safeguard, close() is a no-op if the body length contract is honored. - checkState(!isClosed.get()); - - long bytesRemaining = byteCount; - - while (bytesRemaining != 0) { - Pair> payload = getPendingCronetRead(); - - ByteBuffer readBuffer = payload.first; - SettableFuture future = payload.second; - - int originalBufferLimit = readBuffer.limit(); - int bytesToDrain = (int) Math.min(originalBufferLimit, bytesRemaining); - - readBuffer.limit(bytesToDrain); - - try { - long bytesRead = source.read(readBuffer); - if (bytesRead == -1) { - IOException e = new IOException("The source has been exhausted but we expected more!"); - future.setException(e); - throw e; - } - bytesRemaining -= bytesRead; - readBuffer.limit(originalBufferLimit); - future.set(ReadResult.SUCCESS); - } catch (IOException e) { - future.setException(e); - throw e; - } - } - } - - private Pair> getPendingCronetRead() throws IOException { - try { - return pendingRead.take(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for a read to finish!"); - } - } - - @Override - public void close() { - isClosed.set(true); - } - - @Override - public void flush() { - // Not necessary, we "flush" by sending the data to Cronet straight away when write() is called. - // Note that this class is wrapped with a okio buffer so writes to the outer layer won't be - // seen by this class immediately. - } - - @Override - public Timeout timeout() { - return Timeout.NONE; - } - - enum ReadResult { - SUCCESS, - END_OF_BODY - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountDetailsEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountDetailsEntity.kt index 2e1a7c20f..e8b7911f1 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountDetailsEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountDetailsEntity.kt @@ -1,11 +1,9 @@ package com.tonapps.wallet.api.entity import android.os.Parcelable -import android.util.Log +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.contract.BaseWalletContract import com.tonapps.blockchain.ton.contract.WalletVersion -import com.tonapps.blockchain.ton.extensions.isValidTonAddress -import com.tonapps.blockchain.ton.extensions.toRawAddress import com.tonapps.blockchain.ton.extensions.toUserFriendly import io.tonapi.models.Account import io.tonapi.models.AccountStatus @@ -21,7 +19,7 @@ data class AccountDetailsEntity( val balance: Long, val new: Boolean = false, val initialized: Boolean, - val testnet: Boolean, + val network: TonNetwork, ): Parcelable { val address: String @@ -32,60 +30,60 @@ data class AccountDetailsEntity( constructor( contract: BaseWalletContract, - testnet: Boolean, + network: TonNetwork, new: Boolean = false, initialized: Boolean, ) : this( query = "", - preview = AccountEntity(contract.address, testnet), + preview = AccountEntity(contract.address, network), active = true, walletVersion = contract.getWalletVersion(), balance = 0, new = new, initialized = initialized, - testnet = testnet, + network = network, ) constructor( query: String, account: Account, - testnet: Boolean, + network: TonNetwork, new: Boolean = false ) : this( query = query, - preview = AccountEntity(account, testnet), + preview = AccountEntity(account, network), active = account.status == AccountStatus.active, - walletVersion = resolveVersion(testnet, account.interfaces, account.address), + walletVersion = resolveVersion(network, account.interfaces, account.address), balance = account.balance, new = new, initialized = account.status == AccountStatus.active || account.status == AccountStatus.frozen, - testnet = testnet, + network = network, ) constructor( query: String, wallet: Wallet, - testnet: Boolean, + network: TonNetwork, new: Boolean = false ) : this( query = query, - preview = AccountEntity(wallet, testnet), + preview = AccountEntity(wallet, network), active = wallet.status == AccountStatus.active, - walletVersion = resolveVersion(testnet, wallet.interfaces, wallet.address), + walletVersion = resolveVersion(network, wallet.interfaces, wallet.address), balance = wallet.balance, new = new, initialized = wallet.status == AccountStatus.active || wallet.status == AccountStatus.frozen, - testnet = testnet, + network = network, ) private companion object { - private fun resolveVersion(testnet: Boolean, interfaces: List?, address: String): WalletVersion { + private fun resolveVersion(network: TonNetwork, interfaces: List?, address: String): WalletVersion { val version = resolveVersionByInterface(interfaces) if (version == WalletVersion.UNKNOWN) { return resolveVersionByAddress(address.toUserFriendly( wallet = true, - testnet = testnet, + testnet = network.isTestnet, )) } return version @@ -118,4 +116,4 @@ data class AccountDetailsEntity( } } -} \ No newline at end of file +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountEntity.kt index 9d563eab0..485c8e09e 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AccountEntity.kt @@ -2,6 +2,7 @@ package com.tonapps.wallet.api.entity import android.net.Uri import android.os.Parcelable +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.toAccountId import com.tonapps.blockchain.ton.extensions.toRawAddress import com.tonapps.blockchain.ton.extensions.toUserFriendly @@ -32,8 +33,8 @@ data class AccountEntity( return name } - constructor(address: AddrStd, testnet: Boolean) : this( - address = address.toWalletAddress(testnet), + constructor(address: AddrStd, network: TonNetwork) : this( + address = address.toWalletAddress(network.isTestnet), accountId = address.toAccountId(), name = null, iconUri = null, @@ -41,8 +42,8 @@ data class AccountEntity( isScam = false, ) - constructor(model: AccountAddress, testnet: Boolean): this( - address = model.address.toUserFriendly(model.isWallet, testnet), + constructor(model: AccountAddress, network: TonNetwork): this( + address = model.address.toUserFriendly(model.isWallet, network.isTestnet), accountId = model.address.toRawAddress(), name = model.name, iconUri = model.icon?.toUri(), @@ -50,8 +51,8 @@ data class AccountEntity( isScam = model.isScam ) - constructor(account: Account, testnet: Boolean) : this( - address = account.address.toUserFriendly(account.isWallet, testnet), + constructor(account: Account, network: TonNetwork) : this( + address = account.address.toUserFriendly(account.isWallet, network.isTestnet), accountId = account.address.toRawAddress(), name = account.name, iconUri = account.icon?.toUri(), @@ -59,8 +60,8 @@ data class AccountEntity( isScam = account.isScam ?: false ) - constructor(wallet: Wallet, testnet: Boolean) : this( - address = wallet.address.toUserFriendly(wallet.isWallet, testnet), + constructor(wallet: Wallet, network: TonNetwork) : this( + address = wallet.address.toUserFriendly(wallet.isWallet, network.isTestnet), accountId = wallet.address.toRawAddress(), name = wallet.name, iconUri = wallet.icon?.toUri(), diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AppVersion.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AppVersion.kt index 310a203c2..159214f2a 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AppVersion.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/AppVersion.kt @@ -1,7 +1,6 @@ package com.tonapps.wallet.api.entity import android.os.Parcelable -import android.util.Log import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/BalanceEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/BalanceEntity.kt deleted file mode 100644 index e2f61d979..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/BalanceEntity.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.tonapps.wallet.api.entity - -import android.os.Parcelable -import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.value.Blockchain -import io.tonapi.models.JettonBalance -import io.tonapi.models.TokenRates -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -import java.math.BigDecimal - -@Parcelize -data class BalanceEntity( - val token: TokenEntity, - val value: Coins, - val walletAddress: String, - val initializedAccount: Boolean = true, - val isRequestMinting: Boolean = false, - val isTransferable: Boolean = true, - val lastActivity: Long = -1, -): Parcelable { - - companion object { - - fun empty( - accountId: String, - isCompressed: Boolean, - isTransferable: Boolean - ) = create(accountId, Coins.ZERO, isCompressed, isTransferable) - - fun create( - accountId: String, - value: Coins, - isRequestMinting: Boolean = false, - isTransferable: Boolean = true - ) = BalanceEntity( - token = TokenEntity.TON, - value = value, - walletAddress = accountId, - initializedAccount = false, - isRequestMinting = isRequestMinting, - isTransferable = isTransferable - ) - } - - @IgnoredOnParcel - var rates: TokenRates? = null - - val isTon: Boolean - get() = token.isTon - - val decimals: Int - get() = token.decimals - - val customPayloadApiUri: String? - get() = token.customPayloadApiUri - - val blockchain: Blockchain - get() = token.blockchain - - constructor(jettonBalance: JettonBalance) : this( - token = TokenEntity(jettonBalance.jetton, jettonBalance.extensions, jettonBalance.lock), - value = Coins.of(BigDecimal(jettonBalance.balance).movePointLeft(jettonBalance.jetton.decimals), jettonBalance.jetton.decimals), - walletAddress = jettonBalance.walletAddress.address, - initializedAccount = true, - isRequestMinting = jettonBalance.extensions?.contains(TokenEntity.Extension.CustomPayload.value) == true, - isTransferable = jettonBalance.extensions?.contains(TokenEntity.Extension.NonTransferable.value) != true - ) { - rates = jettonBalance.price - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/BannerEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/BannerEntity.kt new file mode 100644 index 000000000..c93ee8759 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/BannerEntity.kt @@ -0,0 +1,71 @@ +package com.tonapps.wallet.api.entity + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.json.JSONArray +import org.json.JSONObject + +@Parcelize +data class BannerEntity( + val id: String, + val title: String, + val description: String, + val image: String?, + val textColor: String?, + val backgroundColor: String?, + val button: Button? +): Parcelable { + + @Parcelize + data class Button( + val type: Type, + val payload: String, + val title: String + ): Parcelable { + + enum class Type { + LINK, + DEEPLINK; + + companion object { + + fun of(value: String): Type { + return when (value.lowercase()) { + "deeplink" -> DEEPLINK + else -> LINK + } + } + } + } + + constructor(json: JSONObject) : this( + type = Type.of(json.getString("type")), + payload = json.getString("payload"), + title = json.getString("title") + ) + } + + constructor(json: JSONObject) : this( + id = json.getString("id"), + title = json.getString("title"), + description = json.getString("description"), + image = json.optStringOrNull("image"), + textColor = json.optStringOrNull("textColor"), + backgroundColor = json.optStringOrNull("backgroundColor"), + button = json.optJSONObject("button")?.let { Button(it) } + ) + + companion object { + + fun parse(array: JSONArray): List { + return (0 until array.length()).map { BannerEntity(array.getJSONObject(it)) } + } + + private fun JSONObject.optStringOrNull(name: String): String? { + if (!has(name) || isNull(name)) { + return null + } + return optString(name).takeIf { it.isNotBlank() } + } + } +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/ConfigEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/ConfigEntity.kt index cbaacb6d1..f605d0539 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/ConfigEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/ConfigEntity.kt @@ -2,14 +2,13 @@ package com.tonapps.wallet.api.entity import android.net.Uri import android.os.Parcelable -import android.util.Log +import androidx.core.net.toUri import com.tonapps.extensions.toStringList import com.tonapps.icu.Coins +import com.tonapps.wallet.api.Constants import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.json.JSONObject -import androidx.core.net.toUri -import com.tonapps.wallet.api.Constants @Parcelize data class ConfigEntity( @@ -47,6 +46,8 @@ data class ConfigEntity( val stakingInfoUrl: String, val tonapiSSEEndpoint: String, val tonapiSSETestnetEndpoint: String, + val toncenterSSEEndpoint: String, + val toncenterSSETestnetEndpoint: String, val iapPackages: List, val burnZeroDomain: String, val scamAPIURL: String, @@ -65,6 +66,7 @@ data class ConfigEntity( val privacyPolicyUrl: String, val termsOfUseUrl: String, val webSwapsUrl: String, + val tronFeeFaqUrl: String, ): Parcelable { @IgnoredOnParcel @@ -73,7 +75,7 @@ data class ConfigEntity( @IgnoredOnParcel val domains: List by lazy { - listOf(tonapiMainnetHost, tonapiTestnetHost, tonapiSSEEndpoint, tonapiSSETestnetEndpoint, tonConnectBridgeHost, "https://tonapi.io/") + listOf(tonapiMainnetHost, tonapiTestnetHost, tonapiSSEEndpoint, tonapiSSETestnetEndpoint, tonConnectBridgeHost, "https://tonapi.io/", "https://toncenterproxy.tonapi.io/") } @IgnoredOnParcel @@ -120,8 +122,10 @@ data class ConfigEntity( batteryRefundEndpoint = json.optString("batteryRefundEndpoint", "https://battery-refund-app.vercel.app"), batteryPromoDisable = json.optBoolean("disable_battery_promo_module", true), stakingInfoUrl = json.getString("stakingInfoUrl"), - tonapiSSEEndpoint = json.optString("tonapi_sse_endpoint", "https://rt.tonapi.io"), - tonapiSSETestnetEndpoint = json.optString("tonapi_sse_testnet_endpoint", "https://rt-testnet.tonapi.io"), + tonapiSSEEndpoint = json.optString("tonapi_sse_endpoint", "https://rt.tonapi.io").removeSuffix("/"), + tonapiSSETestnetEndpoint = json.optString("tonapi_sse_testnet_endpoint", "https://rt-testnet.tonapi.io").removeSuffix("/"), + toncenterSSEEndpoint = json.optString("tonapi_sse_endpoint_v2", "https://tonapi.io").removeSuffix("/"), + toncenterSSETestnetEndpoint = json.optString("tonapi_sse_testnet_endpoint_v2", "https://testnet.tonapi.io").removeSuffix("/"), iapPackages = json.optJSONArray("iap_packages")?.let { array -> (0 until array.length()).map { IAPPackageEntity(array.getJSONObject(it)) } } ?: emptyList(), @@ -145,7 +149,8 @@ data class ConfigEntity( // tronApiKey = json.optString("tron_api_key"), privacyPolicyUrl = json.getString("privacy_policy"), termsOfUseUrl = json.getString("terms_of_use"), - webSwapsUrl = json.optString("web_swaps_url", Constants.SWAP_PREFIX) + webSwapsUrl = json.optString("web_swaps_url", Constants.SWAP_API), + tronFeeFaqUrl = json.getString("faq_tron_fee_url"), ) constructor() : this( @@ -164,7 +169,7 @@ data class ConfigEntity( tonkeeperNewsUrl = "https://t.me/tonkeeper_new", tonCommunityUrl = "https://t.me/toncoin", tonCommunityChatUrl = "https://t.me/toncoin_chat", - tonApiV2Key = "", + tonApiV2Key = "AF77F5JNEUSNXPQAAAAMDXXG7RBQ3IRP6PC2HTHL4KYRWMZYOUQGDEKYFDKBETZ6FDVZJBI", featuredPlayInterval = 3000, flags = FlagsEntity(), faqUrl = "https://tonkeeper.helpscoutdocs.com/", @@ -183,6 +188,8 @@ data class ConfigEntity( stakingInfoUrl = "https://ton.org/stake", tonapiSSEEndpoint = "https://rt.tonapi.io", tonapiSSETestnetEndpoint = "https://rt-testnet.tonapi.io", + toncenterSSEEndpoint = "https://tonapi.io/streaming/v2/sse", + toncenterSSETestnetEndpoint = "https://testnet.tonapi.io/streaming/v2/sse", iapPackages = emptyList(), burnZeroDomain = "UQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJKZ", scamAPIURL = "https://scam.tonkeeper.com", @@ -199,7 +206,8 @@ data class ConfigEntity( tronSwapTitle = "LetsExchange", privacyPolicyUrl = "https://tonkeeper.com/privacy", termsOfUseUrl = "https://tonkeeper.com/terms", - webSwapsUrl = Constants.SWAP_PREFIX + webSwapsUrl = Constants.SWAP_API, + tronFeeFaqUrl = "https://tonkeeper.helpscoutdocs.com/article/137-multichain" ) fun formatTransactionExplorer(testnet: Boolean, tron: Boolean, hash: String): String { diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/ConfigResponseEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/ConfigResponseEntity.kt new file mode 100644 index 000000000..b1a9da2de --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/ConfigResponseEntity.kt @@ -0,0 +1,20 @@ +package com.tonapps.wallet.api.entity + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.json.JSONObject + +@Parcelize +data class ConfigResponseEntity( + val mainnet: ConfigEntity, + val testnet: ConfigEntity, + val tetra: ConfigEntity, +): Parcelable { + constructor(json: JSONObject, debug: Boolean) : this( + mainnet = ConfigEntity(json.getJSONObject("mainnet"), debug), + testnet = ConfigEntity(json.getJSONObject("testnet"), debug), + tetra = ConfigEntity(json.getJSONObject("tetra"), debug) + ) +} + + diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/EmulateWithBatteryResult.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/EmulateWithBatteryResult.kt new file mode 100644 index 000000000..c84911039 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/EmulateWithBatteryResult.kt @@ -0,0 +1,9 @@ +package com.tonapps.wallet.api.entity + +import io.tonapi.models.MessageConsequences + +data class EmulateWithBatteryResult( + val consequences: MessageConsequences, + val withBattery: Boolean, + val excess: Long?, +) \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/FlagsEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/FlagsEntity.kt index 46dfe74f0..4628fbf26 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/FlagsEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/FlagsEntity.kt @@ -18,7 +18,8 @@ data class FlagsEntity( val disableUsde: Boolean, val disableNativeSwap: Boolean, val disableOnboardingStory: Boolean, - val disableNfts: Boolean + val disableNfts: Boolean, + val disableWalletKit: Boolean ) : Parcelable { constructor(json: JSONObject) : this( @@ -34,7 +35,8 @@ data class FlagsEntity( disableUsde = json.optBoolean("disable_usde", false), disableNativeSwap = json.optBoolean("disable_native_swap", false), disableOnboardingStory = json.optBoolean("disable_onboarding_story", false), - disableNfts = json.optBoolean("disable_nfts", false) + disableNfts = json.optBoolean("disable_nfts", false), + disableWalletKit = json.optBoolean("disable_wallet_kit", true) ) constructor() : this( @@ -50,6 +52,7 @@ data class FlagsEntity( disableUsde = false, disableNativeSwap = false, disableOnboardingStory = false, - disableNfts = false + disableNfts = false, + disableWalletKit = true, ) } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/OnRampMerchantEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/OnRampMerchantEntity.kt index 498fbe376..5dfb9657f 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/OnRampMerchantEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/OnRampMerchantEntity.kt @@ -7,6 +7,7 @@ data class OnRampMerchantEntity( val amount: Double, val widgetUrl: String, val minAmount: Double, + val merchantTxId: String? = null, ) { data class Data( @@ -25,6 +26,7 @@ data class OnRampMerchantEntity( merchant = json.getString("merchant"), amount = json.getDouble("amount"), widgetUrl = json.getString("widget_url"), - minAmount = json.optDouble("min_amount", 0.0) + minAmount = json.optDouble("min_amount", 0.0), + merchantTxId = json.optString("merchant_transaction_id"), ) } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/QRScannerExtendsEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/QRScannerExtendsEntity.kt index 3ff6fadcd..4fc801862 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/QRScannerExtendsEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/QRScannerExtendsEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.api.entity import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.json.JSONArray diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/SwapEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/SwapEntity.kt index dc04e6d3e..fd4453d3e 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/SwapEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/SwapEntity.kt @@ -23,7 +23,8 @@ object SwapEntity { val tradeStartDeadline: String, val gasBudget: String, val estimatedGasConsumption: String, - val slippage: Int + val slippage: Int, + val valueDifferenceBps: Int? = null, ) { val isEmpty: Boolean @@ -40,7 +41,8 @@ object SwapEntity { tradeStartDeadline = "", gasBudget = "", estimatedGasConsumption = "", - slippage = 100 + slippage = 100, + valueDifferenceBps = null, ) fun parse(data: String) = try { diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/TokenEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/TokenEntity.kt deleted file mode 100644 index d5ed94e17..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/TokenEntity.kt +++ /dev/null @@ -1,231 +0,0 @@ -package com.tonapps.wallet.api.entity - -import android.net.Uri -import android.os.Parcelable -import com.tonapps.blockchain.ton.extensions.cellFromHex -import com.tonapps.blockchain.ton.extensions.equalsAddress -import com.tonapps.blockchain.ton.extensions.toRawAddress -import com.tonapps.wallet.api.R -import com.tonapps.wallet.api.entity.value.Blockchain -import io.tonapi.models.JettonBalanceLock -import io.tonapi.models.JettonInfo -import io.tonapi.models.JettonPreview -import io.tonapi.models.JettonTransferPayload -import io.tonapi.models.JettonVerificationType -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -import org.ton.block.StateInit -import org.ton.cell.Cell -import org.ton.tlb.CellRef -import org.ton.tlb.asRef - -@Parcelize -data class TokenEntity( - val blockchain: Blockchain, - val address: String, - val name: String, - val symbol: String, - val imageUri: Uri, - val decimals: Int, - val verification: Verification, - val isRequestMinting: Boolean, - val isTransferable: Boolean, - val lock: Lock? = null, - val customPayloadApiUri: String? -): Parcelable { - - val isTsTON: Boolean - get() = verification == Verification.whitelist && symbol.equals("tsTON", true) - - val isTsUSDe: Boolean - get() = verification == Verification.whitelist && symbol.equals("tsUSDe", true) - - val isLiquid: Boolean - get() = isTsTON || isTsUSDe - - enum class Verification { - whitelist, blacklist, none - } - - @Parcelize - data class Lock( - val amount: String, - val till: Long - ): Parcelable { - - constructor(lock: JettonBalanceLock) : this( - amount = lock.amount, - till = lock.till - ) - } - - data class TransferPayload( - val tokenAddress: String, - val customPayload: Cell? = null, - val stateInit: CellRef? = null - ) { - - companion object { - - fun empty(tokenAddress: String): TransferPayload { - return TransferPayload(tokenAddress) - } - } - - val isEmpty: Boolean - get() = customPayload == null && stateInit == null - - constructor(tokenAddress: String, model: JettonTransferPayload) : this( - tokenAddress = tokenAddress, - customPayload = model.customPayload?.cellFromHex(), - stateInit = model.stateInit?.cellFromHex()?.asRef(StateInit), - ) - } - - enum class Extension(val value: String) { - NonTransferable("non_transferable"), - CustomPayload("custom_payload") - } - - companion object { - - val TON_ICON_URI = Uri.Builder().scheme("res").path(R.drawable.ic_ton_with_bg.toString()).build() - val USDT_ICON_URI = Uri.Builder().scheme("res").path(R.drawable.ic_usdt_with_bg.toString()).build() - val USDE_ICON_URI = Uri.Builder().scheme("res").path(R.drawable.ic_udse_ethena_with_bg.toString()).build() - val TS_USDE_ICON_URI = Uri.Builder().scheme("res").path(R.drawable.ic_tsusde_with_bg.toString()).build() - - const val TRC20_USDT = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" - const val TON_USDT = "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe" - const val TON_USDE = "0:086fa2a675f74347b08dd4606a549b8fdb98829cb282bc1949d3b12fbaed9dcc" - - const val TON_TS_USDE = "0:d0e545323c7acb7102653c073377f7e3c67f122eb94d430a250739f109d4a57d" - - val TON = TokenEntity( - blockchain = Blockchain.TON, - address = "TON", - name = "Toncoin", - symbol = "TON", - imageUri = TON_ICON_URI, - decimals = 9, - verification = Verification.whitelist, - isRequestMinting = false, - isTransferable = true, - customPayloadApiUri = null - ) - - val USDT = TokenEntity( - blockchain = Blockchain.TON, - address = TON_USDT, - name = "Tether", - symbol = "USD₮", - imageUri = USDT_ICON_URI, - decimals = 6, - verification = Verification.whitelist, - isRequestMinting = false, - isTransferable = true, - customPayloadApiUri = null - ) - - val TRON_USDT = TokenEntity( - blockchain = Blockchain.TRON, - address = TRC20_USDT, - name = "Tether", - symbol = "USD₮", - imageUri = USDT_ICON_URI, - decimals = 6, - verification = Verification.whitelist, - isRequestMinting = false, - isTransferable = true, - customPayloadApiUri = null - ) - - val USDE = TokenEntity( - blockchain = Blockchain.TON, - address = TON_USDE, - name = "Ethena USDe", - symbol = "USDe", - imageUri = USDE_ICON_URI, - decimals = 6, - verification = Verification.whitelist, - isRequestMinting = false, - isTransferable = true, - customPayloadApiUri = null - ) - - val TS_USDE = TokenEntity( - blockchain = Blockchain.TON, - address = TON_TS_USDE, - name = "Ethena tsUSDe", - symbol = "tsUSDe", - imageUri = TS_USDE_ICON_URI, - decimals = 6, - verification = Verification.whitelist, - isRequestMinting = false, - isTransferable = true, - customPayloadApiUri = null - ) - - private fun convertVerification(verification: JettonVerificationType): Verification { - return when (verification) { - JettonVerificationType.whitelist -> Verification.whitelist - JettonVerificationType.blacklist -> Verification.blacklist - else -> Verification.none - } - } - } - - val isTon: Boolean - get() = address == TON.address - - @IgnoredOnParcel - val isUsdt: Boolean by lazy { - address.equalsAddress(TON_USDT) - } - - @IgnoredOnParcel - val isTrc20: Boolean by lazy { - address == TRC20_USDT - } - - val verified: Boolean - get() = verification == Verification.whitelist - - val blacklist: Boolean - get() = verification == TokenEntity.Verification.blacklist - - constructor( - jetton: JettonPreview, - extensions: List? = null, - lock: JettonBalanceLock? = null - ) : this( - blockchain = Blockchain.TON, - address = jetton.address.toRawAddress(), - name = jetton.name, - symbol = jetton.symbol, - imageUri = Uri.parse(jetton.image), - decimals = jetton.decimals, - verification = convertVerification(jetton.verification), - isRequestMinting = extensions?.contains(Extension.CustomPayload.value) == true, - isTransferable = extensions?.contains(Extension.NonTransferable.value) != true, - lock = lock?.let { Lock(it) }, - customPayloadApiUri = jetton.customPayloadApiUri - ) - - constructor( - jetton: JettonInfo, - extensions: List? = null, - lock: JettonBalanceLock? = null - ) : this( - blockchain = Blockchain.TON, - address = jetton.metadata.address.toRawAddress(), - name = jetton.metadata.name, - symbol = jetton.metadata.symbol, - imageUri = Uri.parse(jetton.preview ?: jetton.metadata.image), - decimals = jetton.metadata.decimals.toInt(), - verification = convertVerification(jetton.verification), - isRequestMinting = extensions?.contains(Extension.CustomPayload.value) == true, - isTransferable = extensions?.contains(Extension.NonTransferable.value) != true, - lock = lock?.let { Lock(it) }, - customPayloadApiUri = jetton.metadata.customPayloadApiUri - ) -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/toncenter/ToncenterEvent.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/toncenter/ToncenterEvent.kt new file mode 100644 index 000000000..2faaf10b2 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/toncenter/ToncenterEvent.kt @@ -0,0 +1,155 @@ +package com.tonapps.wallet.api.entity.toncenter + +import org.json.JSONObject + +sealed class ToncenterEvent( + val type: String, + val finality: String, +) { + + companion object { + + fun parse(json: JSONObject): ToncenterEvent? { + val type = json.optString("type") + val finality = json.optString("finality", "") + return when (type) { + "transactions" -> TransactionsEvent(json, finality) + "actions" -> ActionsEvent(json, finality) + "trace" -> TraceEvent(json, finality) + "account_state_change" -> AccountStateEvent(json, finality) + "jettons_change" -> JettonsEvent(json, finality) + "trace_invalidated" -> TraceInvalidatedEvent(json) + else -> null + } + } + } +} + +class TransactionsEvent( + val json: JSONObject, + finality: String, +) : ToncenterEvent("transactions", finality) { + + val traceExternalHashNorm: String? + get() = json.optString("trace_external_hash_norm").takeIf { it.isNotEmpty() } + + val transactions: org.json.JSONArray? + get() = json.optJSONArray("transactions") + + val addressBook: JSONObject? + get() = json.optJSONObject("address_book") + + val metadata: JSONObject? + get() = json.optJSONObject("metadata") +} + +class ActionsEvent( + val json: JSONObject, + finality: String, +) : ToncenterEvent("actions", finality) { + + val traceExternalHashNorm: String? + get() = json.optString("trace_external_hash_norm").takeIf { it.isNotEmpty() } + + val actions: org.json.JSONArray? + get() = json.optJSONArray("actions") + + val addressBook: JSONObject? + get() = json.optJSONObject("address_book") + + val metadata: JSONObject? + get() = json.optJSONObject("metadata") +} + +class TraceEvent( + val json: JSONObject, + finality: String, +) : ToncenterEvent("trace", finality) { + + val traceExternalHashNorm: String? + get() = json.optString("trace_external_hash_norm").takeIf { it.isNotEmpty() } + + val trace: JSONObject? + get() = json.optJSONObject("trace") + + val transactions: JSONObject? + get() = json.optJSONObject("transactions") + + val actions: org.json.JSONArray? + get() = json.optJSONArray("actions") + + val addressBook: JSONObject? + get() = json.optJSONObject("address_book") + + val metadata: JSONObject? + get() = json.optJSONObject("metadata") +} + +class AccountStateEvent( + val json: JSONObject, + finality: String, +) : ToncenterEvent("account_state_change", finality) { + + val account: String + get() = json.optString("account", "") + + val state: AccountState? + get() = json.optJSONObject("state")?.let { AccountState(it) } +} + +data class AccountState( + val hash: String, + val balance: String, + val accountStatus: String, + val dataHash: String, + val codeHash: String, +) { + + constructor(json: JSONObject) : this( + hash = json.optString("hash", ""), + balance = json.optString("balance", ""), + accountStatus = json.optString("account_status", ""), + dataHash = json.optString("data_hash", ""), + codeHash = json.optString("code_hash", ""), + ) +} + +class JettonsEvent( + val json: JSONObject, + finality: String, +) : ToncenterEvent("jettons_change", finality) { + + val jetton: JettonChange? + get() = json.optJSONObject("jetton")?.let { JettonChange(it) } + + val addressBook: JSONObject? + get() = json.optJSONObject("address_book") + + val metadata: JSONObject? + get() = json.optJSONObject("metadata") +} + +data class JettonChange( + val address: String, + val balance: String, + val owner: String, + val jetton: String, + val lastTransactionLt: String, +) { + + constructor(json: JSONObject) : this( + address = json.optString("address", ""), + balance = json.optString("balance", ""), + owner = json.optString("owner", ""), + jetton = json.optString("jetton", ""), + lastTransactionLt = json.optString("last_transaction_lt", ""), + ) +} + +class TraceInvalidatedEvent( + val json: JSONObject, +) : ToncenterEvent("trace_invalidated", "") { + + val traceExternalHashNorm: String? + get() = json.optString("trace_external_hash_norm").takeIf { it.isNotEmpty() } +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/toncenter/ToncenterSSERequest.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/toncenter/ToncenterSSERequest.kt new file mode 100644 index 000000000..c11f93828 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/toncenter/ToncenterSSERequest.kt @@ -0,0 +1,27 @@ +package com.tonapps.wallet.api.entity.toncenter + +import org.json.JSONArray +import org.json.JSONObject + +data class ToncenterSSERequest( + val addresses: List, + val types: List, + val minFinality: String, + val traceExternalHashNorms: List? = null, + val includeAddressBook: Boolean? = null, + val includeMetadata: Boolean? = null, + val actionTypes: List? = null, + val supportedActionTypes: List? = null, +) { + + fun toJSON(): JSONObject = JSONObject().apply { + put("addresses", JSONArray(addresses)) + put("types", JSONArray(types)) + put("min_finality", minFinality) + traceExternalHashNorms?.let { put("trace_external_hash_norms", JSONArray(it)) } + includeAddressBook?.let { put("include_address_book", it) } + includeMetadata?.let { put("include_metadata", it) } + actionTypes?.let { put("action_types", JSONArray(it)) } + supportedActionTypes?.let { put("supported_action_types", JSONArray(it)) } + } +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/Blockchain.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/Blockchain.kt deleted file mode 100644 index 38d1499d7..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/Blockchain.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.tonapps.wallet.api.entity.value - -enum class Blockchain(val id: String) { - TON("TON"), - TRON("TRON"); -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/BlockchainAddress.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/BlockchainAddress.kt deleted file mode 100644 index 3ff6a6180..000000000 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/BlockchainAddress.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tonapps.wallet.api.entity.value - -import android.os.Parcelable -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Parcelize -data class BlockchainAddress( - val value: String, - val testnet: Boolean, - val blockchain: Blockchain, -): Parcelable { - - @IgnoredOnParcel - val key: String by lazy { - if (testnet) { - "${blockchain.id}:$value:testnet" - } else { - "${blockchain.id}:$value" - } - } - - companion object { - - fun valueOf(value: String): BlockchainAddress { - val split = value.split(":") - return if (split.size == 2) { - BlockchainAddress( - value = split[1], - testnet = false, - blockchain = Blockchain.valueOf(split[0]) - ) - } else { - BlockchainAddress( - value = split[2], - testnet = split[1] == "testnet", - blockchain = Blockchain.valueOf(split[0]) - ) - } - } - } -} \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/ValueConverters.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/ValueConverters.kt index b07afe671..65ed562e5 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/ValueConverters.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/entity/value/ValueConverters.kt @@ -1,6 +1,8 @@ package com.tonapps.wallet.api.entity.value import androidx.room.TypeConverter +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.BlockchainAddress object ValueConverters { diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/extensions/JettonExt.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/extensions/JettonExt.kt new file mode 100644 index 000000000..5defe91e0 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/extensions/JettonExt.kt @@ -0,0 +1,66 @@ +package com.tonapps.wallet.api.extensions + +import androidx.core.net.toUri +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity.Extension +import com.tonapps.blockchain.model.legacy.TokenEntity.Lock +import com.tonapps.blockchain.model.legacy.TokenEntity.Verification +import com.tonapps.blockchain.ton.extensions.toRawAddress +import io.tonapi.models.JettonBalanceLock +import io.tonapi.models.JettonInfo +import io.tonapi.models.JettonPreview +import io.tonapi.models.JettonVerificationType + +// TODO move +fun JettonPreview.toTokenEntity( + extensions: List? = null, + lock: JettonBalanceLock? = null +): TokenEntity { + val jetton = this + return TokenEntity( + blockchain = Blockchain.TON, + address = jetton.address.toRawAddress(), + name = jetton.name, + symbol = jetton.symbol, + imageUri = jetton.image.toUri(), + decimals = jetton.decimals, + verification = jetton.verification.convertVerification(), + isRequestMinting = extensions?.contains(Extension.CustomPayload.value) == true, + isTransferable = extensions?.contains(Extension.NonTransferable.value) != true, + lock = lock?.let { Lock(it.amount, it.till) }, + customPayloadApiUri = jetton.customPayloadApiUri, + numerator = jetton.scaledUi?.numerator?.toBigDecimal(), + denominator = jetton.scaledUi?.denominator?.toBigDecimal() + ) +} + +// TODO move +fun JettonInfo.toTokenEntity( + extensions: List? = null, + lock: JettonBalanceLock? = null, +): TokenEntity { + val jetton = this + return TokenEntity( + blockchain = Blockchain.TON, + address = jetton.metadata.address.toRawAddress(), + name = jetton.metadata.name, + symbol = jetton.metadata.symbol, + imageUri = jetton.preview.toUri(), + decimals = jetton.metadata.decimals.toInt(), + verification = jetton.verification.convertVerification(), + isRequestMinting = extensions?.contains(Extension.CustomPayload.value) == true, + isTransferable = extensions?.contains(Extension.NonTransferable.value) != true, + lock = lock?.let { Lock(it.amount, it.till) }, + customPayloadApiUri = jetton.metadata.customPayloadApiUri + ) +} + + +private fun JettonVerificationType.convertVerification(): Verification { + return when (this) { + JettonVerificationType.whitelist -> Verification.whitelist + JettonVerificationType.blacklist -> Verification.blacklist + else -> Verification.none + } +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/ConfigRepository.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/ConfigRepository.kt index 21ba64b93..3afeff37c 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/ConfigRepository.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/ConfigRepository.kt @@ -1,10 +1,12 @@ package com.tonapps.wallet.api.internal import android.content.Context +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.file import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toParcel import com.tonapps.wallet.api.entity.ConfigEntity +import com.tonapps.wallet.api.entity.ConfigResponseEntity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -18,18 +20,23 @@ internal class ConfigRepository( private val internalApi: InternalApi, ) { - private val configFile = context.cacheDir.file("config") + private val configFile = context.cacheDir.file("config_all") private val _stream = MutableStateFlow(ConfigEntity.default) val stream = _stream.asStateFlow() - var configEntity: ConfigEntity = ConfigEntity.default - private set (value) { + var configMainnetEntity: ConfigEntity = ConfigEntity.default + private set(value) { field = value - _stream.value = value.copy() internalApi.setApiUrl(value.tonkeeperApiUrl) } + var configTestnetEntity: ConfigEntity = ConfigEntity.default + private set + + var configTetraEntity: ConfigEntity = ConfigEntity.default + private set + init { scope.launch(Dispatchers.IO) { val cached = readCache() @@ -41,32 +48,43 @@ internal class ConfigRepository( } } - private suspend fun setConfig(config: ConfigEntity) = withContext(Dispatchers.Main) { - configEntity = config + private suspend fun setConfig(config: ConfigResponseEntity) = withContext(Dispatchers.Main) { + configMainnetEntity = config.mainnet + configTestnetEntity = config.testnet + configTetraEntity = config.tetra + _stream.value = configMainnetEntity } - private fun readCache(): ConfigEntity? { + private fun readCache(): ConfigResponseEntity? { if (configFile.exists() && configFile.length() > 0) { return configFile.readBytes().toParcel() } return null } - private suspend fun remote(testnet: Boolean): ConfigEntity? = withContext(Dispatchers.IO) { - val config = internalApi.downloadConfig(testnet) ?: return@withContext null - configFile.writeBytes(config.toByteArray()) - config + private suspend fun remote(): ConfigResponseEntity? = withContext(Dispatchers.IO) { + val response = internalApi.downloadConfig() ?: return@withContext null + configFile.writeBytes(response.toByteArray()) + response } - suspend fun refresh(testnet: Boolean) { - val config = remote(testnet) ?: return + suspend fun refresh() { + val config = remote() ?: return setConfig(config) } suspend fun initConfig() { - remote(false)?.let { + remote()?.let { setConfig(it) } } + fun getConfig(network: TonNetwork): ConfigEntity { + return when (network) { + TonNetwork.MAINNET -> configMainnetEntity + TonNetwork.TESTNET -> configTestnetEntity + TonNetwork.TETRA -> configTetraEntity + } + } + } \ No newline at end of file diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/InternalApi.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/InternalApi.kt index 380086a4b..1dc11f378 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/InternalApi.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/internal/InternalApi.kt @@ -1,25 +1,29 @@ package com.tonapps.wallet.api.internal import android.content.Context -import android.util.Log import androidx.collection.ArrayMap import androidx.core.net.toUri import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.isDebug import com.tonapps.extensions.locale import com.tonapps.extensions.map import com.tonapps.network.get import com.tonapps.network.postJSON -import com.tonapps.wallet.api.entity.ConfigEntity +import com.tonapps.wallet.api.configs.CountryConfig +import com.tonapps.wallet.api.entity.BannerEntity +import com.tonapps.wallet.api.entity.ConfigResponseEntity import com.tonapps.wallet.api.entity.EthenaEntity import com.tonapps.wallet.api.entity.NotificationEntity import com.tonapps.wallet.api.entity.OnRampArgsEntity import com.tonapps.wallet.api.entity.StoryEntity +import com.tonapps.wallet.api.readBody import com.tonapps.wallet.api.withRetry import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import org.json.JSONArray import org.json.JSONObject import java.util.Locale @@ -29,16 +33,15 @@ internal class InternalApi( private val appVersionName: String ) { - private var _deviceCountry: String? = null - private var _storeCountry: String? = null + private var config: CountryConfig? = null + private var _apiEndpoint = "https://api.tonkeeper.com".toUri() val country: String - get() = _storeCountry ?: _deviceCountry ?: Locale.getDefault().country.uppercase() + get() = config?.storeCountry ?: config?.deviceCountry?: config?.simCountry ?: Locale.getDefault().country.uppercase() - fun setCountry(deviceCountry: String, storeCountry: String?) { - _deviceCountry = deviceCountry.uppercase() - _storeCountry = storeCountry?.uppercase() + fun setConfig(newConfig: CountryConfig) { + config = newConfig } fun setApiUrl(url: String) { @@ -47,7 +50,7 @@ internal class InternalApi( private fun endpoint( path: String, - testnet: Boolean, + network: TonNetwork, platform: String, build: String, boot: Boolean = false, @@ -61,20 +64,34 @@ internal class InternalApi( } else { _apiEndpoint.buildUpon() } + val chainName = when (network) { + TonNetwork.TESTNET -> "testnet" + TonNetwork.MAINNET -> "mainnet" + TonNetwork.TETRA -> "mainnet" + } builder .appendEncodedPath(path) .appendQueryParameter("lang", context.locale.language) .appendQueryParameter("build", build) .appendQueryParameter("platform", platform) - .appendQueryParameter("chainName", if (testnet) "testnet" else "mainnet") + .appendQueryParameter("chainName", chainName) .appendQueryParameter("bundle_id", context.packageName) - _storeCountry?.let { + config?.storeCountry?.let { builder.appendQueryParameter("store_country_code", it) } - _deviceCountry?.let { + config?.deviceCountry?.let { builder.appendQueryParameter("device_country_code", it) } + config?.simCountry?.let { + builder.appendQueryParameter("sim_country", it) + } + config?.timezone?.let { + builder.appendQueryParameter("timezone", it) + } + config?.isVpn?.let { + builder.appendQueryParameter("is_vpn_active", it.toString()) + } queryParams.forEach { builder.appendQueryParameter(it.key, it.value) @@ -85,7 +102,7 @@ internal class InternalApi( private fun request( path: String, - testnet: Boolean, + network: TonNetwork, platform: String = "android", build: String = appVersionName, locale: Locale, @@ -93,30 +110,70 @@ internal class InternalApi( queryParams: Map = emptyMap(), bootFallback: Boolean = false, ): JSONObject { - val url = endpoint(path, testnet, platform, build, boot, queryParams, bootFallback) + val url = endpoint(path, network, platform, build, boot, queryParams, bootFallback) val headers = ArrayMap() headers["Accept-Language"] = locale.toString() val body = withRetry { okHttpClient.get(url, headers) } ?: throw IllegalStateException("Internal API request failed") - return JSONObject(body) + + return JSONObject( + requestRaw( + path, + network, + platform, + build, + locale, + boot, + queryParams, + ) + ) + } + + private fun requestRaw( + path: String, + network: TonNetwork, + platform: String = "android", + build: String = appVersionName, + locale: Locale, + boot: Boolean = false, + queryParams: Map = emptyMap(), + bootFallback: Boolean = false, + ): String { + val url = endpoint(path, network, platform, build, boot, queryParams, bootFallback) + val headers = ArrayMap() + headers["Accept-Language"] = locale.toString() + val body = withRetry { + okHttpClient.get(url, headers) + } ?: throw IllegalStateException("Internal API request failed") + return body } private fun swapEndpoint(prefix: String, path: String): String { val builder = prefix.toUri().buildUpon() .appendEncodedPath(path) - _deviceCountry?.let { - builder.appendQueryParameter("device_country_code", _deviceCountry) - builder.appendQueryParameter("country", _storeCountry ?: _deviceCountry) + config?.deviceCountry?.let { + builder.appendQueryParameter("device_country_code", it) + } + config?.storeCountry?.let { + builder.appendQueryParameter("store_country_code", it) } - _storeCountry?.let { - builder.appendQueryParameter("store_country_code", _storeCountry) + config?.simCountry?.let { + builder.appendQueryParameter("sim_country", it) + } + config?.timezone?.let { + builder.appendQueryParameter("timezone", it) + } + config?.isVpn?.let { + builder.appendQueryParameter("is_vpn_active", it.toString().lowercase()) } return builder.build().toString() } - fun getOnRampData(prefix: String) = withRetry { - okHttpClient.get(swapEndpoint(prefix, "v2/onramp/currencies")) + fun getOnRampData(prefix: String): String? { + return withRetry { + okHttpClient.get(swapEndpoint(prefix, "v2/onramp/currencies")) + } } fun getOnRampPaymentMethods(prefix: String, currency: String) = withRetry { @@ -129,17 +186,36 @@ internal class InternalApi( fun calculateOnRamp(prefix: String, args: OnRampArgsEntity): String? { val json = args.toJSON() - _deviceCountry?.let { json.put("country", _deviceCountry) } + config?.deviceCountry?.let { + json.put("device_country_code", it) + } + config?.storeCountry?.let { + json.put("store_country_code", it) + } + config?.simCountry?.let { + json.put("sim_country", it) + } + config?.timezone?.let { + json.put("timezone", it) + } + config?.isVpn?.let { + json.put("is_vpn_active", it) + } return withRetry { okHttpClient.postJSON( swapEndpoint(prefix, "v2/onramp/calculate"), json.toString() - ).body.string() + ).readBody() } } fun getNotifications(): List { - val json = request("notifications", false, locale = context.locale) + val json = request( + path = "notifications", + network = TonNetwork.MAINNET, + locale = context.locale, + boot = false + ) val array = json.getJSONArray("notifications") val list = mutableListOf() for (i in 0 until array.length()) { @@ -173,29 +249,44 @@ internal class InternalApi( return (maskDomains + cleanDomains + telegramBots).toTypedArray() } - fun getBrowserApps(testnet: Boolean, locale: Locale): JSONObject { - val data = request("apps/popular", testnet, locale = locale) + fun getBrowserApps(network: TonNetwork, locale: Locale): JSONObject { + val data = request("apps/popular", network, locale = locale) return data.getJSONObject("data") } - fun getFiatMethods(testnet: Boolean = false, locale: Locale): JSONObject { - val data = request("fiat/methods", testnet, locale = locale) + fun getBanners(network: TonNetwork = TonNetwork.MAINNET): List = withRetry { + val json = request( + path = "banners", + network = network, + locale = context.locale, + boot = false + ) + BannerEntity.parse(json.getJSONArray("banners")) + } ?: emptyList() + + fun getCurrencies(network: TonNetwork = TonNetwork.MAINNET, locale: Locale): JSONArray { + val data = request("currencies", network, locale = locale) + return data.getJSONArray("currencies") + } + + fun getFiatMethods(network: TonNetwork = TonNetwork.MAINNET, locale: Locale): JSONObject { + val data = request("fiat/methods", network, locale = locale) return data.getJSONObject("data") } - fun downloadConfig(testnet: Boolean, fallback: Boolean = false): ConfigEntity? { + fun downloadConfig(fallback: Boolean = false): ConfigResponseEntity? { return try { val json = request( - "keys", - testnet, + "keys/all", + network = TonNetwork.MAINNET, locale = context.locale, boot = true, bootFallback = fallback ) - ConfigEntity(json, context.isDebug) + ConfigResponseEntity(json, context.isDebug) } catch (e: Throwable) { if (!fallback) { - downloadConfig(testnet, true) + downloadConfig(true) } else { FirebaseCrashlytics.getInstance().recordException(e) null @@ -205,7 +296,12 @@ internal class InternalApi( fun getStories(id: String): StoryEntity.Stories? { return try { - val json = request("stories/$id", false, locale = context.locale) + val json = request( + path = "stories/$id", + network = TonNetwork.MAINNET, + locale = context.locale, + boot = false + ) val pages = json.getJSONArray("pages") val list = mutableListOf() for (i in 0 until pages.length()) { @@ -224,7 +320,12 @@ internal class InternalApi( suspend fun resolveCountry(): String? = withContext(Dispatchers.IO) { try { - val json = request("my/ip", false, locale = context.locale) + val json = request( + path = "my/ip", + network = TonNetwork.MAINNET, + locale = context.locale, + boot = false + ) val country = json.getString("country") if (country.isNullOrBlank()) { null @@ -239,9 +340,10 @@ internal class InternalApi( fun getEthena(accountId: String): EthenaEntity? = withRetry { val json = request( - "staking/ethena", - false, + path = "staking/ethena", + network = TonNetwork.MAINNET, locale = context.locale, + boot = false, queryParams = mapOf("address" to accountId) ) EthenaEntity(json) diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/TronApi.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/TronApi.kt index 322f1258e..cd11b60ae 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/TronApi.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/TronApi.kt @@ -3,30 +3,44 @@ package com.tonapps.wallet.api.tron import android.net.Uri import androidx.collection.arrayMapOf import androidx.core.net.toUri +import com.tonapps.blockchain.ton.extensions.base64 import com.tonapps.blockchain.tron.TronTransaction import com.tonapps.blockchain.tron.TronTransfer import com.tonapps.blockchain.tron.encodeTronAddress import com.tonapps.blockchain.tron.tronHex +import com.tonapps.extensions.CacheKey +import com.tonapps.extensions.TimedCacheMemory +import com.tonapps.extensions.fromHex import com.tonapps.extensions.map import com.tonapps.icu.Coins import com.tonapps.network.get import com.tonapps.network.postJSON -import com.tonapps.wallet.api.entity.BalanceEntity +import com.tonapps.network.backoff.ExponentialBackoff +import com.tonapps.blockchain.model.legacy.BalanceEntity import com.tonapps.wallet.api.entity.ConfigEntity -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import com.tonapps.wallet.api.entity.value.Timestamp +import com.tonapps.wallet.api.readBody import com.tonapps.wallet.api.tron.entity.TronEstimationEntity import com.tonapps.wallet.api.tron.entity.TronEventEntity +import com.tonapps.wallet.api.tron.entity.TronResourcePrices import com.tonapps.wallet.api.tron.entity.TronResourcesEntity import com.tonapps.wallet.api.withRetry import io.batteryapi.apis.DefaultApi +import io.batteryapi.models.EstimatedTronTx +import io.batteryapi.models.EstimatedTronTxInstantFeeAcceptedAssetsInner import io.batteryapi.models.TronSendRequest import io.ktor.util.encodeBase64 +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import okhttp3.OkHttpClient import org.json.JSONObject +import org.ton.cell.Cell import java.math.BigInteger class TronApi( @@ -35,13 +49,19 @@ class TronApi( private val batteryApi: DefaultApi ) { - companion object { - private const val DATA_HEX_PROTOBUF_EXTRA = 9 - private const val MAX_RESULT_SIZE_IN_TX = 64 - private const val A_SIGNATURE = 67 + sealed interface Keys : CacheKey { + object TronPrices : Keys { + override val ttl: Long = CacheKey.DEFAULT_TTL + } } + val transferDefaultResources = TronResourcesEntity( + energy = 64285, + bandwidth = 345, + ) + private var safetyMargin: Double? = null + private val resourcePricesCache = TimedCacheMemory() private val tronApiKey: String? get() = config.tronApiKey?.ifBlank { null } @@ -79,7 +99,7 @@ class TronApi( } val response = post(url, requestBody) - val body = response.body.string() + val body = response.readBody() val json = JSONObject(body) val constantResultArray = json.optJSONArray("constant_result") @@ -91,7 +111,7 @@ class TronApi( value = Coins.of(balance.toLong(), TokenEntity.TRON_USDT.decimals), walletAddress = tronAddress, ) - } catch (e: Throwable) { + } catch (_: Throwable) { return BalanceEntity( token = TokenEntity.TRON_USDT, value = Coins.of(BigInteger.ZERO, TokenEntity.TRON_USDT.decimals), @@ -100,6 +120,33 @@ class TronApi( } } + fun getTrxBalance( + tronAddress: String, + ): BalanceEntity { + return try { + val builder = config.tronApiUrl.toUri().buildUpon() + .appendEncodedPath("v1/accounts/$tronAddress") + val url = builder.build() + + val body = get(url) + val json = JSONObject(body).getJSONArray("data") + + val balanceSun = json.optJSONObject(0)?.optLong("balance") ?: 0L + + return BalanceEntity( + token = TokenEntity.TRX, + value = Coins.of(balanceSun, TokenEntity.TRX.decimals), + walletAddress = tronAddress, + ) + } catch (_: Throwable) { + BalanceEntity( + token = TokenEntity.TRX, + value = Coins.of(BigInteger.ZERO, TokenEntity.TRX.decimals), + walletAddress = tronAddress, + ) + } + } + private fun getTronBlockchainHistory( tronAddress: String, limit: Int, @@ -118,7 +165,15 @@ class TronApi( val body = get(builder.build()) val json = JSONObject(body).getJSONArray("data") - return json.map { TronEventEntity(it) } + return json.map { + if (it.getString("type") == "Transfer" && it.getJSONObject("token_info") + .getString("address") == TokenEntity.TRC20_USDT + ) { + TronEventEntity(it) + } else { + null + } + }.filterNotNull() } private fun getBatteryTransfersHistory( @@ -134,21 +189,37 @@ class TronApi( return response.transactions.filter { it.txid.isNotEmpty() }.map { TronEventEntity(it) } } - fun getTronHistory( + suspend fun getTronHistory( tronAddress: String, tonProofToken: String, limit: Int, beforeTimestamp: Timestamp?, afterTimestamp: Timestamp? = null, ): List { - val blockchainEvents = getTronBlockchainHistory(tronAddress, limit, beforeTimestamp, afterTimestamp) - val batteryEvents = getBatteryTransfersHistory(tonProofToken, limit, beforeTimestamp) + val (blockchainEvents, batteryEvents) = coroutineScope { + val blockchainEventsDeferred = async { + getTronBlockchainHistory( + tronAddress, + limit, + beforeTimestamp, + afterTimestamp + ) + } + val batteryEventsDeferred = + async { getBatteryTransfersHistory(tonProofToken, limit, beforeTimestamp) } + Pair(blockchainEventsDeferred.await(), batteryEventsDeferred.await()) + } return (batteryEvents + blockchainEvents) .distinctBy { it.transactionHash } .sortedByDescending { it.timestamp } } + private fun estimateBandwidth(rawHex: String): Int { + return rawHex.fromHex().size + + DATA_HEX_PROTOBUF_EXTRA + MAX_RESULT_SIZE_IN_TX + A_SIGNATURE + } + private fun estimateResources(transfer: TronTransfer): TronResourcesEntity { val builder = config.tronApiUrl.toUri().buildUpon() @@ -164,7 +235,7 @@ class TronApi( } val response = post(url, requestBody) - val body = response.body.string() + val body = response.readBody() val json = JSONObject(body) val resultObj = json.optJSONObject("result") @@ -181,12 +252,64 @@ class TronApi( val rawHex = transaction?.optString("raw_data_hex") ?: throw Exception("Transaction data missing in response") - val bandwidth = (rawHex.length / 2) + - DATA_HEX_PROTOBUF_EXTRA + MAX_RESULT_SIZE_IN_TX + A_SIGNATURE + val bandwidth = estimateBandwidth(rawHex) return TronResourcesEntity(energy = energy, bandwidth = bandwidth) } + private suspend fun getResourcePrices(): TronResourcePrices { + return resourcePricesCache.getOrLoad(Keys.TronPrices) { + val builder = config.tronApiUrl.toUri().buildUpon() + .appendEncodedPath("wallet/getchainparameters") + val url = builder.build() + + val requestBody = buildJsonObject { } + + val response = post(url, requestBody) + val body = response.readBody() + val json = JSONObject(body) + + val paramsArray = json.optJSONArray("chainParameter") + ?: throw Exception("Missing chainParameter array in response") + + fun getValue(key: String): Long? { + for (i in 0 until paramsArray.length()) { + val obj = paramsArray.optJSONObject(i) ?: continue + if (obj.optString("key") == key) { + val value = obj.opt("value") + if (value is Number) { + return value.toLong() + } + } + } + return null + } + + val energySun = getValue("getEnergyFee") + val bandwidthSun = getValue("getTransactionFee") + + if (energySun == null || bandwidthSun == null) { + throw Exception("Missing or invalid energy or bandwidth price in chain parameters") + } + + TronResourcePrices( + energy = Coins.of(energySun, TokenEntity.TRX.decimals), + bandwidth = Coins.of(bandwidthSun, TokenEntity.TRX.decimals) + ) + } + } + + suspend fun getBurnTrxAmountForResources(resources: TronResourcesEntity): Coins { + val prices = getResourcePrices() + val burnTrxForEnergy = prices.energy.value * resources.energy.toBigDecimal() + val burnTrxForBandwidth = prices.bandwidth.value * resources.bandwidth.toBigDecimal() + + return Coins.of( + burnTrxForEnergy + burnTrxForBandwidth, + TokenEntity.TRX.decimals + ) + } + private fun applyResourcesSafetyMargin(resources: TronResourcesEntity): TronResourcesEntity { val margin = safetyMargin ?: run { val tronConfig = batteryApi.getTronConfig() @@ -199,30 +322,72 @@ class TronApi( val energy = kotlin.math.ceil(resources.energy * (1 + margin)).toInt() val bandwidth = kotlin.math.ceil(resources.bandwidth * (1 + margin)).toInt() - return TronResourcesEntity(energy = energy, bandwidth = bandwidth) + return resources.copy(energy = energy, bandwidth = bandwidth) } private fun getAccountBandwidth(tronAddress: String): Int { - val builder = - config.tronApiUrl.toUri().buildUpon().appendEncodedPath("v1/accounts/$tronAddress") + val url = config.tronApiUrl.toUri() + .buildUpon() + .appendEncodedPath("wallet/getaccountnet") + .build() - val body = get(builder.build()) + val requestBody = buildJsonObject { + put("address", tronAddress) + put("visible", true) + } + + val response = post(url, requestBody) + val body = response.readBody() val json = JSONObject(body) - val dataArray = json.optJSONArray("data") + val freeNetLimit = json.optLong("freeNetLimit", 0L) + if (freeNetLimit <= 0) return 0 - val info = dataArray.optJSONObject(0) - return info?.optInt("free_net_usage", 0) ?: 0 + val freeNetUsed = json.optLong("freeNetUsed", 0L) + + val available = freeNetLimit - freeNetUsed + + return if (available > 0) available.toInt() else 0 + } + + private fun broadcastSignedTransaction( + transaction: TronTransaction, + ) { + val builder = config.tronApiUrl.toUri().buildUpon() + .appendEncodedPath("wallet/broadcasttransaction") + val url = builder.build() + + val jsonBody = Json.parseToJsonElement(transaction.json.toString()).jsonObject + val response = post(url, jsonBody) + val body = response.readBody() + val json = JSONObject(body) + + val ok = json.optBoolean("result") + if (!ok) { + val message = json.optString("message", "Broadcast failed") + throw Exception("Broadcast failed: $message") + } } - fun estimateBatteryCharges(transfer: TronTransfer): TronEstimationEntity { + + fun estimateTransferResources(transfer: TronTransfer): TronResourcesEntity { var resources = applyResourcesSafetyMargin(estimateResources(transfer)) val bandwidthAvailable = getAccountBandwidth(transfer.from) - resources = resources.copy( - bandwidth = kotlin.math.max(0, resources.bandwidth - bandwidthAvailable) - ) - val estimation = withRetry { + if (bandwidthAvailable > resources.bandwidth) { + resources = resources.copy( + bandwidth = 0 + ) + } + + return resources + } + + fun estimateBatteryCharges( + transfer: TronTransfer, + resources: TronResourcesEntity + ): TronEstimationEntity.Charges { + val estimated = withRetry { batteryApi.tronEstimate( wallet = transfer.from, energy = resources.energy, @@ -230,13 +395,63 @@ class TronApi( ) } ?: throw Exception("tron api failed") - return TronEstimationEntity( - charges = estimation.totalCharges, - resources = resources, + return TronEstimationEntity.Charges( + charges = estimated.totalCharges, + estimated = estimated, + ) + } + + suspend fun estimateTrxFee(resources: TronResourcesEntity): TronEstimationEntity.TrxFee { + val fee = getBurnTrxAmountForResources(resources) + return TronEstimationEntity.TrxFee(fee = fee) + } + + fun estimateTonFee( + batteryEstimated: EstimatedTronTx, + ): TronEstimationEntity.TonFee { + val tonInstantFee = batteryEstimated.instantFee.acceptedAssets.find { + it.type == EstimatedTronTxInstantFeeAcceptedAssetsInner.Type.ton + } ?: throw Exception("Instant fee for ton not allowed") + + return TronEstimationEntity.TonFee( + fee = Coins.ofNano(tonInstantFee.amountNano, TokenEntity.TON.decimals), + sendToAddress = batteryEstimated.instantFee.feeAddress, ) } - fun sendTransaction( + fun sendWithTon( + transaction: TronTransaction, + instantFeeTx: Cell, + resources: TronResourcesEntity, + tronAddress: String, + batteryAuthToken: String, + userPublicKey: String, + ) { + val base64 = transaction.json.toString().encodeBase64() + val request = TronSendRequest( + wallet = tronAddress, + tx = base64, + energy = resources.energy, + bandwidth = resources.bandwidth, + instantFeeTx = instantFeeTx.base64() + ) + + batteryApi.tronSend( + request, + xTonConnectAuth = batteryAuthToken, + userPublicKey = userPublicKey + ) + } + + fun sendWithTrx( + transaction: TronTransaction, + resources: TronResourcesEntity, + tronAddress: String, + ) { + broadcastSignedTransaction(transaction) + } + + fun sendWithBattery( transaction: TronTransaction, resources: TronResourcesEntity, tronAddress: String, @@ -268,23 +483,22 @@ class TronApi( } val response = post(url, requestBody) - val body = response.body.string() + val body = response.readBody() val json = JSONObject(body) return TronTransaction(json = json.getJSONObject("transaction")) } private fun tronRetry(retryBlock: () -> R) = withRetry( - delay = (1000L..3000L).random() + times = 3, + backoff = ExponentialBackoff(minDelayMs = 100, maxDelayMs = 500) ) { retryBlock() } - fun activateWallet( - tronAddress: String, - tonProofToken: String, - ) { - batteryApi.tronSend(TronSendRequest(wallet = tronAddress, tx = ""), tonProofToken) + companion object { + private const val DATA_HEX_PROTOBUF_EXTRA = 9 + private const val MAX_RESULT_SIZE_IN_TX = 64 + private const val A_SIGNATURE = 67 } - -} \ No newline at end of file +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEstimationEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEstimationEntity.kt index dce320aec..2bebbcb05 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEstimationEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEstimationEntity.kt @@ -1,3 +1,10 @@ package com.tonapps.wallet.api.tron.entity -data class TronEstimationEntity(val charges: Int, val resources: TronResourcesEntity) +import com.tonapps.icu.Coins +import io.batteryapi.models.EstimatedTronTx + +sealed class TronEstimationEntity { + data class Charges(val charges: Int, val estimated: EstimatedTronTx) + data class TrxFee(val fee: Coins) + data class TonFee(val fee: Coins, val sendToAddress: String) +} diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEventEntity.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEventEntity.kt index d4087d9a4..c3fb07857 100644 --- a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEventEntity.kt +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronEventEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.api.tron.entity import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import com.tonapps.wallet.api.entity.value.Timestamp import io.batteryapi.models.TronTransactionsListTransactionsInner import org.json.JSONObject diff --git a/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronResourcePrices.kt b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronResourcePrices.kt new file mode 100644 index 000000000..5aaf08577 --- /dev/null +++ b/apps/wallet/api/src/main/java/com/tonapps/wallet/api/tron/entity/TronResourcePrices.kt @@ -0,0 +1,8 @@ +package com.tonapps.wallet.api.tron.entity + +import com.tonapps.icu.Coins + +data class TronResourcePrices( + val energy: Coins, + val bandwidth: Coins +) diff --git a/apps/wallet/api/src/main/res/drawable/ic_ton_with_bg.xml b/apps/wallet/api/src/main/res/drawable/ic_ton_with_bg.xml deleted file mode 100644 index 273849af4..000000000 --- a/apps/wallet/api/src/main/res/drawable/ic_ton_with_bg.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/apps/wallet/data/account/build.gradle.kts b/apps/wallet/data/account/build.gradle.kts index 38b476884..08bb4a926 100644 --- a/apps/wallet/data/account/build.gradle.kts +++ b/apps/wallet/data/account/build.gradle.kts @@ -1,16 +1,12 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") kotlin("plugin.serialization") } -android { - namespace = Build.namespacePrefix("wallet.data.account") -} - dependencies { - implementation(libs.kotlinX.serialization.json) - implementation(libs.kotlinX.coroutines.android) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) implementation(libs.koin.core) implementation(libs.ton.tvm) implementation(libs.ton.crypto) @@ -18,15 +14,16 @@ dependencies { implementation(libs.ton.blockTlb) implementation(libs.ton.tonapiTl) implementation(libs.ton.contract) - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.rn)) - implementation(project(ProjectModules.Wallet.Data.rates)) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Lib.security)) - implementation(project(ProjectModules.Lib.network)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.sqlite)) - implementation(project(ProjectModules.Lib.ledger)) + implementation(projects.tonapi.legacy) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.rn) + implementation(projects.apps.wallet.data.rates) + implementation(projects.apps.wallet.api) + implementation(projects.lib.security) + implementation(projects.lib.network) + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) + implementation(projects.lib.sqlite) + implementation(projects.lib.ledger) + implementation(projects.kmp.async) } diff --git a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/AccountRepository.kt b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/AccountRepository.kt index b31d52ee2..137bf86df 100644 --- a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/AccountRepository.kt +++ b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/AccountRepository.kt @@ -2,7 +2,15 @@ package com.tonapps.wallet.data.account import android.app.KeyguardManager import android.content.Context +import com.tonapps.async.Async import com.tonapps.blockchain.MnemonicHelper +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.BlockchainAddress +import com.tonapps.blockchain.model.legacy.MessageBodyEntity +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.contract.BaseWalletContract import com.tonapps.blockchain.ton.contract.WalletVersion import com.tonapps.blockchain.ton.contract.walletVersion @@ -13,10 +21,6 @@ import com.tonapps.blockchain.ton.extensions.toAccountId import com.tonapps.blockchain.tron.KeychainTrxAccountsProvider import com.tonapps.ledger.ton.LedgerAccount import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.value.BlockchainAddress -import com.tonapps.wallet.data.account.entities.MessageBodyEntity -import com.tonapps.wallet.data.account.entities.WalletEntity import com.tonapps.wallet.data.account.source.DatabaseSource import com.tonapps.wallet.data.account.source.StorageSource import com.tonapps.wallet.data.account.source.VaultSource @@ -25,9 +29,7 @@ import com.tonapps.wallet.data.rn.RNLegacy import com.tonapps.wallet.data.rn.data.RNKeystone import com.tonapps.wallet.data.rn.data.RNLedger import com.tonapps.wallet.data.rn.data.RNWallet -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged @@ -51,12 +53,6 @@ class AccountRepository( private val rnLegacy: RNLegacy, ) { - companion object { - fun newWalletId(): String { - return UUID.randomUUID().toString() - } - } - sealed class SelectedState { data object Initialization : SelectedState() data object Empty : SelectedState() @@ -67,7 +63,7 @@ class AccountRepository( context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager } - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val scope = Async.ioScope() private val database = DatabaseSource(context, scope) private val storageSource: StorageSource by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { StorageSource(context) } private val vaultSource: VaultSource by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { VaultSource(context) } @@ -79,9 +75,12 @@ class AccountRepository( SharingStarted.Eagerly, SelectedState.Initialization ) - val selectedWalletFlow = selectedStateFlow.filterNotNull().filterIsInstance().map { - it.wallet - }.shareIn(scope, SharingStarted.Eagerly, 1).distinctUntilChanged() + val selectedWalletFlow = selectedStateFlow + .filterNotNull() + .filterIsInstance() + .map { it.wallet } + .shareIn(scope, SharingStarted.Eagerly, 1) + .distinctUntilChanged() private val selectedId: String? get() = (selectedStateFlow.value as? SelectedState.Wallet)?.wallet?.id @@ -100,6 +99,18 @@ class AccountRepository( } } + suspend fun requiredSelectedWallet(): WalletEntity { + val selected = selectedStateFlow.value + if (selected is SelectedState.Wallet) { + return selected.wallet + } + + val selectedId = storageSource.getSelectedId() + val selectedWallet = selectedId?.let { database.getAccount(it) } + + return requireNotNull(selectedWallet) + } + suspend fun setInitialized(id: String, initialized: Boolean) { try { database.setInitialized(id, initialized) @@ -141,13 +152,13 @@ class AccountRepository( private suspend fun addWalletToRN(wallet: WalletEntity) { val type = when (wallet.type) { - Wallet.Type.Default -> RNWallet.Type.Regular - Wallet.Type.Watch -> RNWallet.Type.WatchOnly - Wallet.Type.Lockup -> RNWallet.Type.Lockup - Wallet.Type.SignerQR -> RNWallet.Type.Signer - Wallet.Type.Signer -> RNWallet.Type.SignerDeeplink - Wallet.Type.Ledger -> RNWallet.Type.Ledger - Wallet.Type.Keystone -> RNWallet.Type.Keystone + WalletType.Default -> RNWallet.Type.Regular + WalletType.Watch -> RNWallet.Type.WatchOnly + WalletType.Lockup -> RNWallet.Type.Lockup + WalletType.SignerQR -> RNWallet.Type.Signer + WalletType.Signer -> RNWallet.Type.SignerDeeplink + WalletType.Ledger -> RNWallet.Type.Ledger + WalletType.Keystone -> RNWallet.Type.Keystone else -> RNWallet.Type.Regular } @@ -229,7 +240,7 @@ class AccountRepository( private suspend fun saveTonProof(wallet: WalletEntity, token: String) = withContext(Dispatchers.IO) { storageSource.setTonProofToken(wallet.publicKey, token) - val wallets = getWalletByPublicKey(wallet.publicKey, wallet.testnet) + val wallets = getWalletByPublicKey(wallet.publicKey, wallet.network) for (w in wallets) { rnLegacy.setTonProof(w.id, token) } @@ -279,7 +290,7 @@ class AccountRepository( val address = getTronAddress(id) ?: return null return BlockchainAddress( value = address, - testnet = false, + network = TonNetwork.MAINNET, blockchain = Blockchain.TRON ) } @@ -300,7 +311,7 @@ class AccountRepository( val entity = WalletEntity( id = newWalletId(), publicKey = account.publicKey, - type = Wallet.Type.Ledger, + type = WalletType.Ledger, version = WalletVersion.V4R2, label = label.create(index), ledger = WalletEntity.Ledger( @@ -323,7 +334,7 @@ class AccountRepository( qr: Boolean, initialized: List ): List { - val type = if (qr) Wallet.Type.SignerQR else Wallet.Type.Signer + val type = if (qr) WalletType.SignerQR else WalletType.Signer return addWallet(versions.map { newWalletId() }, label, publicKey, versions, type, initialized = initialized) } @@ -336,7 +347,7 @@ class AccountRepository( val entity = WalletEntity( id = newWalletId(), publicKey = publicKey, - type = Wallet.Type.Keystone, + type = WalletType.Keystone, version = WalletVersion.V4R2, label = label.create(0), keystone = keystone, @@ -353,11 +364,10 @@ class AccountRepository( label: Wallet.NewLabel, mnemonic: List, versions: List, - testnet: Boolean, - initialized: List + type: WalletType, + initialized: List, ): List { val publicKey = vaultSource.addMnemonic(mnemonic) - val type = if (testnet) Wallet.Type.Testnet else Wallet.Type.Default return addWallet(ids, label, publicKey, versions, type, initialized = initialized) } @@ -366,7 +376,7 @@ class AccountRepository( label: Wallet.NewLabel, publicKey: PublicKeyEd25519, versions: List, - type: Wallet.Type, + type: WalletType, initialized: List ): List { val list = mutableListOf() @@ -391,7 +401,7 @@ class AccountRepository( publicKey: PublicKeyEd25519, version: WalletVersion, ): WalletEntity { - return addWallet(newWalletId(), label, publicKey, Wallet.Type.Watch, version, initialized = false) + return addWallet(newWalletId(), label, publicKey, WalletType.Watch, version, initialized = false) } suspend fun addNewWallet( @@ -400,14 +410,14 @@ class AccountRepository( mnemonic: List ): WalletEntity { val publicKey = vaultSource.addMnemonic(mnemonic) - return addWallet(id, label, publicKey, Wallet.Type.Default, WalletVersion.V5R1, new = true, initialized = false) + return addWallet(id, label, publicKey, WalletType.Default, WalletVersion.V5R1, new = true, initialized = false) } private suspend fun addWallet( id: String, label: Wallet.NewLabel, publicKey: PublicKeyEd25519, - type: Wallet.Type, + type: WalletType, version: WalletVersion, new: Boolean = false, initialized: Boolean @@ -439,7 +449,7 @@ class AccountRepository( val payload = api.tonconnectPayload() ?: return null return try { val publicKey = wallet.publicKey - val contract = BaseWalletContract.create(publicKey, WalletVersion.V4R2.title, wallet.testnet) + val contract = BaseWalletContract.create(publicKey, WalletVersion.V4R2.title, wallet.network) val secretKey = vaultSource.getPrivateKey(publicKey) ?: throw Exception("private key not found") val address = contract.address val proof = WalletProof.signTonkeeper( @@ -485,20 +495,20 @@ class AccountRepository( setSelectedWallet(null) } - suspend fun getWalletsByAccountId(accountId: String, testnet: Boolean): List { + suspend fun getWalletsByAccountId(accountId: String, network: TonNetwork): List { return database.getAccounts().filter { - it.accountId.equalsAddress(accountId) && it.testnet == testnet + it.accountId.equalsAddress(accountId) && it.network == network } } - suspend fun getWalletByPublicKey(publicKey: PublicKeyEd25519, testnet: Boolean): List { + suspend fun getWalletByPublicKey(publicKey: PublicKeyEd25519, network: TonNetwork): List { return database.getAccounts().filter { - it.publicKey == publicKey && it.testnet == testnet + it.publicKey == publicKey && it.network == network } } - suspend fun getWalletByAccountId(accountId: String, testnet: Boolean = false): WalletEntity? { - val wallets = getWalletsByAccountId(accountId, testnet) + suspend fun getWalletByAccountId(accountId: String, network: TonNetwork = TonNetwork.MAINNET): WalletEntity? { + val wallets = getWalletsByAccountId(accountId, network) if (wallets.isEmpty()) { return null } @@ -516,20 +526,26 @@ class AccountRepository( return database.getAccount(id) } + suspend fun forceSelectedWallet(): WalletEntity { + return (_selectedStateFlow.value as? SelectedState.Wallet)?.wallet + ?: database.getAccount(selectedId!!)!! + } + suspend fun getSelectedWallet(): WalletEntity? { - return (_selectedStateFlow.value as? SelectedState.Wallet)?.wallet ?: database.getAccount(selectedId ?: return null) + return (_selectedStateFlow.value as? SelectedState.Wallet)?.wallet + ?: database.getAccount(selectedId ?: return null) } suspend fun getSeqno( wallet: WalletEntity ): Int = withContext(Dispatchers.IO) { - api.getAccountSeqno(wallet.accountId, wallet.testnet) + api.getAccountSeqno(wallet.accountId, wallet.network) } suspend fun getValidUntil( - testnet: Boolean + network: TonNetwork ): Long = withContext(Dispatchers.IO) { - val seconds = api.getServerTime(testnet) + val seconds = api.getServerTime(network) seconds + (5 * 30L) // 5 minutes } @@ -551,8 +567,14 @@ class AccountRepository( return messageBody( wallet = wallet, seqNo = seqNo, - validUntil = if (validUntil > 0) validUntil else getValidUntil(wallet.testnet), + validUntil = if (validUntil > 0) validUntil else getValidUntil(wallet.network), transfers = transfers ) } -} \ No newline at end of file + + companion object { + fun newWalletId(): String { + return UUID.randomUUID().toString() + } + } +} diff --git a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/RNMigrationHelper.kt b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/RNMigrationHelper.kt index 68badafc5..fb9f49de7 100644 --- a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/RNMigrationHelper.kt +++ b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/RNMigrationHelper.kt @@ -1,10 +1,11 @@ package com.tonapps.wallet.data.account +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.blockchain.model.legacy.WalletType import com.tonapps.blockchain.ton.contract.WalletVersion import com.tonapps.blockchain.ton.contract.walletVersion -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.rn.RNLegacy -import com.tonapps.wallet.data.rn.data.RNDecryptedData import com.tonapps.wallet.data.rn.data.RNVaultState import com.tonapps.wallet.data.rn.data.RNWallet import com.tonapps.wallet.data.rn.data.RNWallet.Companion.int @@ -46,23 +47,23 @@ internal class RNMigrationHelper( emoji = emoji, color = legacyWallet.color.int, ) - val type: Wallet.Type + val type: WalletType if (legacyWallet.network == RNWallet.Network.Testnet) { - type = Wallet.Type.Testnet + type = WalletType.Testnet } else if (legacyWallet.type == RNWallet.Type.Regular) { - type = Wallet.Type.Default + type = WalletType.Default } else if (legacyWallet.type == RNWallet.Type.WatchOnly) { - type = Wallet.Type.Watch + type = WalletType.Watch } else if (legacyWallet.type == RNWallet.Type.Lockup) { - type = Wallet.Type.Lockup + type = WalletType.Lockup } else if (legacyWallet.type == RNWallet.Type.SignerDeeplink) { - type = Wallet.Type.Signer + type = WalletType.Signer } else if (legacyWallet.type == RNWallet.Type.Signer) { - type = Wallet.Type.SignerQR + type = WalletType.SignerQR } else if (legacyWallet.type == RNWallet.Type.Ledger) { - type = Wallet.Type.Ledger + type = WalletType.Ledger } else if (legacyWallet.type == RNWallet.Type.Keystone) { - type = Wallet.Type.Keystone + type = WalletType.Keystone } else { continue } diff --git a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/Wallet.kt b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/Wallet.kt deleted file mode 100644 index 7ce2decbf..000000000 --- a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/Wallet.kt +++ /dev/null @@ -1,81 +0,0 @@ -package com.tonapps.wallet.data.account - -import android.graphics.Color -import android.os.Parcelable -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -sealed class Wallet { - - companion object { - - fun typeOf(id: Int): Type { - return Type.entries.find { it.id == id } ?: Type.Default - } - } - - enum class Type(val id: Int) { - Default(0), Watch(1), Testnet(2), Signer(3), Lockup(4), Ledger(5), SignerQR(6), Keystone(7) - } - - @Parcelize - data class Label( - val accountName: String = "", - val emoji: CharSequence = "", - val color: Int = WalletColor.all.first() - ): Parcelable { - - @IgnoredOnParcel - val isEmpty: Boolean by lazy { - accountName.isBlank() && emoji.isBlank() - } - - val name: String - get() = accountName - - @IgnoredOnParcel - val title: CharSequence? by lazy { - if (isEmpty) { - null - } else if (emoji.startsWith("custom_")) { - name - } else { - String.format("%s %s", emoji, name) - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - other as Label - if (accountName != other.accountName) return false - if (emoji != other.emoji) return false - if (color != other.color) return false - return true - } - - override fun hashCode(): Int { - var result = color - result = 31 * result + accountName.hashCode() - result = 31 * result + emoji.hashCode() - result = 31 * result + isEmpty.hashCode() - result = 31 * result + name.hashCode() - result = 31 * result + (title?.hashCode() ?: 0) - return result - } - } - - - data class NewLabel( - val names: List, - val emoji: CharSequence, - val color: Int - ) { - - fun create(index: Int): Label { - val name = names.getOrNull(index) ?: "Wallet" - return Label(name, emoji, color) - } - } - -} \ No newline at end of file diff --git a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/entities/WalletEvent.kt b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/entities/WalletEvent.kt index c0e94bf56..7d6277a92 100644 --- a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/entities/WalletEvent.kt +++ b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/entities/WalletEvent.kt @@ -1,5 +1,6 @@ package com.tonapps.wallet.data.account.entities +import com.tonapps.blockchain.model.legacy.WalletEntity import org.json.JSONObject sealed class WalletEvent( diff --git a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/DatabaseSource.kt b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/DatabaseSource.kt index 7f86e70e9..096e1993d 100644 --- a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/DatabaseSource.kt +++ b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/DatabaseSource.kt @@ -5,18 +5,18 @@ import android.content.Context import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper -import android.util.Log import androidx.core.database.getStringOrNull +import androidx.core.database.sqlite.transaction import com.tonapps.blockchain.ton.contract.walletVersion import com.tonapps.extensions.closeSafe import com.tonapps.extensions.isNullOrEmpty import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toParcel import com.tonapps.sqlite.withTransaction -import com.tonapps.wallet.data.account.Wallet -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.model.legacy.WalletEntity import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.ton.api.pub.PublicKeyEd25519 @@ -209,13 +209,13 @@ internal class DatabaseSource( label = label, initialized = cursor.getInt(initializedIndex) == 1 ) - if (wallet.type == Wallet.Type.Ledger) { + if (wallet.type == WalletType.Ledger) { val ledger = WalletEntity.Ledger( deviceId = cursor.getString(ledgerDeviceIdIndex), accountIndex = cursor.getInt(ledgerAccountIndexIndex) ) wallet = wallet.copy(ledger = ledger) - } else if (wallet.type == Wallet.Type.Keystone) { + } else if (wallet.type == WalletType.Keystone) { val keystone = WalletEntity.Keystone( xfp = cursor.getStringOrNull(keystoneXfpIndex) ?: "", path = cursor.getStringOrNull(keystonePathIndex) ?: "" diff --git a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/StorageSource.kt b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/StorageSource.kt index c286c9cc7..48511bc3f 100644 --- a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/StorageSource.kt +++ b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/StorageSource.kt @@ -1,11 +1,9 @@ package com.tonapps.wallet.data.account.source import android.content.Context -import android.content.SharedPreferences import androidx.core.content.edit import com.tonapps.blockchain.ton.extensions.hex import com.tonapps.extensions.prefs -import com.tonapps.extensions.putStringIfNotExists import com.tonapps.extensions.string import com.tonapps.security.Security import org.ton.api.pub.PublicKeyEd25519 @@ -20,16 +18,16 @@ internal class StorageSource(context: Context) { } private val prefs = context.prefs(NAME) - private val encryptedPrefs: SharedPreferences by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, NAME) } + private val encryptedPrefs by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, NAME) } fun getTonProofToken(publicKey: PublicKeyEd25519): String? { val key = tonProofTokenKey(publicKey) - return encryptedPrefs.string(key) ?: prefs.string(key) + return encryptedPrefs.get(key) ?: prefs.string(key) } fun setTonProofToken(publicKey: PublicKeyEd25519, token: String) { val key = tonProofTokenKey(publicKey) - encryptedPrefs.putStringIfNotExists(key, token) + encryptedPrefs.putIfNotExist(key, token) } fun getSelectedId(): String? { diff --git a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/VaultSource.kt b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/VaultSource.kt index 596441fc7..d661d7b58 100644 --- a/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/VaultSource.kt +++ b/apps/wallet/data/account/src/main/java/com/tonapps/wallet/data/account/source/VaultSource.kt @@ -1,11 +1,12 @@ package com.tonapps.wallet.data.account.source import android.content.Context -import androidx.core.content.edit +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.blockchain.MnemonicHelper -import com.tonapps.blockchain.ton.extensions.getPrivateKey +import com.tonapps.blockchain.ton.extensions.decodePrivateKey import com.tonapps.blockchain.ton.extensions.hex import com.tonapps.extensions.putByteArray +import com.tonapps.security.KeyHelperException import com.tonapps.security.Security import com.tonapps.security.clear import kotlinx.coroutines.Dispatchers @@ -13,7 +14,6 @@ import kotlinx.coroutines.withContext import org.json.JSONObject import org.ton.api.pk.PrivateKeyEd25519 import org.ton.api.pub.PublicKeyEd25519 -import org.ton.mnemonic.Mnemonic internal class VaultSource(context: Context) { @@ -28,14 +28,14 @@ internal class VaultSource(context: Context) { fun getVaultKeys(): String { val result = JSONObject() - for ((key, value) in prefs.all) { + for ((key, value) in prefs.all()) { result.put(key, value.toString()) } return result.toString() } fun getMnemonic(publicKey: PublicKeyEd25519): Array? { - val value = prefs.getString(mnemonicKey(publicKey), null) ?: return null + val value = prefs.get(mnemonicKey(publicKey)) ?: return null val mnemonic = value.split(",").toTypedArray() if (mnemonic.isEmpty()) { return null @@ -48,22 +48,49 @@ internal class VaultSource(context: Context) { val seed = privateKey.key.toByteArray() val publicKey = privateKey.publicKey() - prefs.edit { - putString(mnemonicKey(publicKey), mnemonic.joinToString(",")) - putByteArray(privateKey(publicKey), seed) + try { + val success = prefs.transaction { + putString(mnemonicKey(publicKey), mnemonic.joinToString(",")) + putByteArray(pkKey(publicKey), seed) + } + + if (!success) { + throw KeyHelperException.AddMnemonic().also { + FirebaseCrashlytics.getInstance() + .recordException(it) + } + } + } finally { + seed.clear() } - seed.clear() return publicKey } - suspend fun getPrivateKey(publicKey: PublicKeyEd25519): PrivateKeyEd25519? = withContext(Dispatchers.IO) { - val privateKey = prefs.getPrivateKey(privateKey(publicKey)) + suspend fun getPrivateKey(publicKey: PublicKeyEd25519): PrivateKeyEd25519 = withContext(Dispatchers.IO) { + val privateKey = prefs.get(pkKey(publicKey)) + ?.decodePrivateKey() + if (privateKey == null) { - val fromMnemonic = getPrivateKeyFromMnemonic(publicKey) ?: return@withContext null - prefs.edit { - putByteArray(privateKey(publicKey), fromMnemonic.key.toByteArray()) + val fromMnemonic = getPrivateKeyFromMnemonic(publicKey) + ?: run { + throw KeyHelperException.GetPkFromMnemonic().also { + FirebaseCrashlytics.getInstance() + .recordException(it) + } + } + + val success = prefs.transaction { + putByteArray(pkKey(publicKey), fromMnemonic.key.toByteArray()) + } + + if (!success) { + throw KeyHelperException.GetMnemonic().also { + FirebaseCrashlytics.getInstance() + .recordException(it) + } } + fromMnemonic } else { privateKey @@ -72,13 +99,11 @@ internal class VaultSource(context: Context) { private fun getPrivateKeyFromMnemonic(publicKey: PublicKeyEd25519): PrivateKeyEd25519? { val mnemonic = getMnemonic(publicKey) ?: return null - val seed = Mnemonic.toSeed(mnemonic.toList()) - val privateKey = PrivateKeyEd25519(seed) - seed.clear() + val privateKey = MnemonicHelper.privateKey(mnemonic.toList()) return privateKey } - private fun privateKey(publicKey: PublicKeyEd25519) = key(PRIVATE_KEY_PREFIX, publicKey) + private fun pkKey(publicKey: PublicKeyEd25519) = key(PRIVATE_KEY_PREFIX, publicKey) private fun mnemonicKey(publicKey: PublicKeyEd25519) = key(MNEMONIC_KEY_PREFIX, publicKey) diff --git a/apps/wallet/data/backup/build.gradle.kts b/apps/wallet/data/backup/build.gradle.kts index f9429764e..848f079ba 100644 --- a/apps/wallet/data/backup/build.gradle.kts +++ b/apps/wallet/data/backup/build.gradle.kts @@ -1,15 +1,13 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.backup") -} - dependencies { - implementation(project(ProjectModules.Lib.sqlite)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Wallet.Data.rn)) + implementation(libs.koin.core) + + implementation(projects.lib.sqlite) + implementation(projects.lib.extensions) + implementation(projects.apps.wallet.data.rn) } diff --git a/apps/wallet/data/backup/src/main/java/com/tonapps/wallet/data/backup/source/LocalDataSource.kt b/apps/wallet/data/backup/src/main/java/com/tonapps/wallet/data/backup/source/LocalDataSource.kt index a274ec8ad..aebcf30f2 100644 --- a/apps/wallet/data/backup/src/main/java/com/tonapps/wallet/data/backup/source/LocalDataSource.kt +++ b/apps/wallet/data/backup/src/main/java/com/tonapps/wallet/data/backup/source/LocalDataSource.kt @@ -3,11 +3,11 @@ package com.tonapps.wallet.data.backup.source import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase -import android.util.Log +import com.tonapps.log.L import com.tonapps.sqlite.SQLiteHelper import com.tonapps.wallet.data.backup.entities.BackupEntity -internal class LocalDataSource(context: Context): SQLiteHelper(context, "backup", 1) { +internal class LocalDataSource(context: Context) : SQLiteHelper(context, BACKUP_TABLE_NAME, 1) { private companion object { private const val BACKUP_TABLE_NAME = "backup" @@ -32,11 +32,6 @@ internal class LocalDataSource(context: Context): SQLiteHelper(context, "backup" return getBackups(query) } - fun getBackups(walletId: Long): List { - val query = "SELECT $BACKUP_COLUMN_ID,$BACKUP_COLUMN_SOURCE,$BACKUP_COLUMN_WALLET_ID,$BACKUP_COLUMN_DATE FROM $BACKUP_TABLE_NAME WHERE $BACKUP_COLUMN_WALLET_ID = ?;" - return getBackups(query, arrayOf(walletId.toString())) - } - fun getBackup(backupId: Long): BackupEntity? { val query = "SELECT $BACKUP_COLUMN_ID,$BACKUP_COLUMN_SOURCE,$BACKUP_COLUMN_WALLET_ID,$BACKUP_COLUMN_DATE FROM $BACKUP_TABLE_NAME WHERE $BACKUP_COLUMN_ID = ?;" return getBackups(query, arrayOf(backupId.toString())).firstOrNull() diff --git a/apps/wallet/data/banner/build.gradle.kts b/apps/wallet/data/banner/build.gradle.kts new file mode 100644 index 000000000..2272f022f --- /dev/null +++ b/apps/wallet/data/banner/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("target.android.library") + id("kotlin-parcelize") +} + +dependencies { + implementation(libs.okhttp) + implementation(libs.koin.core) + + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + + implementation(projects.lib.blockchain) + implementation(projects.lib.network) + implementation(projects.lib.extensions) +} diff --git a/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/BannerRepository.kt b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/BannerRepository.kt new file mode 100644 index 000000000..23f6ce64d --- /dev/null +++ b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/BannerRepository.kt @@ -0,0 +1,64 @@ +package com.tonapps.wallet.data.banner + +import android.content.Context +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.wallet.api.API +import com.tonapps.wallet.api.entity.BannerEntity +import com.tonapps.wallet.data.banner.entities.BannerDataEntity +import com.tonapps.wallet.data.banner.source.HiddenBannersDataSource +import com.tonapps.wallet.data.banner.source.LocalDataSource +import com.tonapps.wallet.data.banner.source.RemoteDataSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class BannerRepository(context: Context, api: API) { + + private val localDataSource: LocalDataSource by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + LocalDataSource(context) + } + + private val hiddenDataSource: HiddenBannersDataSource by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + HiddenBannersDataSource(context) + } + + private val remoteDataSource = RemoteDataSource(api) + + suspend fun getBanners( + walletId: String, + network: TonNetwork = TonNetwork.MAINNET, + refresh: Boolean = false, + ): List = withContext(Dispatchers.IO) { + val data = if (refresh) { + loadRemote(network) ?: loadLocal() + } else { + loadLocal() + } ?: return@withContext emptyList() + filterHidden(walletId, data.banners) + } + + fun hideBanner(walletId: String, bannerId: String) { + hiddenDataSource.hide(walletId, bannerId) + } + + private fun filterHidden(walletId: String, banners: List): List { + val hidden = hiddenDataSource.getHidden(walletId) + if (hidden.isEmpty()) { + return banners + } + return banners.filter { it.id !in hidden } + } + + private fun loadLocal(): BannerDataEntity? { + return localDataSource.getCache(CACHE_KEY) + } + + private suspend fun loadRemote(network: TonNetwork): BannerDataEntity? { + val data = remoteDataSource.load(network) ?: return null + localDataSource.setCache(CACHE_KEY, data) + return data + } + + private companion object { + private const val CACHE_KEY = "banner_data" + } +} diff --git a/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/Module.kt b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/Module.kt new file mode 100644 index 000000000..e789f8524 --- /dev/null +++ b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/Module.kt @@ -0,0 +1,8 @@ +package com.tonapps.wallet.data.banner + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val bannerModule = module { + singleOf(::BannerRepository) +} diff --git a/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/entities/BannerDataEntity.kt b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/entities/BannerDataEntity.kt new file mode 100644 index 000000000..c557abbec --- /dev/null +++ b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/entities/BannerDataEntity.kt @@ -0,0 +1,10 @@ +package com.tonapps.wallet.data.banner.entities + +import android.os.Parcelable +import com.tonapps.wallet.api.entity.BannerEntity +import kotlinx.parcelize.Parcelize + +@Parcelize +data class BannerDataEntity( + val banners: List +): Parcelable diff --git a/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/HiddenBannersDataSource.kt b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/HiddenBannersDataSource.kt new file mode 100644 index 000000000..50641bda8 --- /dev/null +++ b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/HiddenBannersDataSource.kt @@ -0,0 +1,26 @@ +package com.tonapps.wallet.data.banner.source + +import android.content.Context +import androidx.core.content.edit + +internal class HiddenBannersDataSource(context: Context) { + + private val prefs = context.getSharedPreferences("banners_hidden", Context.MODE_PRIVATE) + + fun getHidden(walletId: String): Set { + return prefs.getStringSet(key(walletId), null)?.toSet() ?: emptySet() + } + + fun hide(walletId: String, bannerId: String) { + val hidden = getHidden(walletId).toMutableSet() + if (hidden.add(bannerId)) { + prefs.edit { putStringSet(key(walletId), hidden) } + } + } + + fun clear(walletId: String) { + prefs.edit { remove(key(walletId)) } + } + + private fun key(walletId: String) = "hidden_$walletId" +} diff --git a/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/LocalDataSource.kt b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/LocalDataSource.kt new file mode 100644 index 000000000..fa31cebaa --- /dev/null +++ b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/LocalDataSource.kt @@ -0,0 +1,16 @@ +package com.tonapps.wallet.data.banner.source + +import android.content.Context +import com.tonapps.extensions.toByteArray +import com.tonapps.extensions.toParcel +import com.tonapps.wallet.data.banner.entities.BannerDataEntity +import com.tonapps.wallet.data.core.BlobDataSource + +internal class LocalDataSource(context: Context): BlobDataSource( + context = context, + path = "banner_data" +) { + override fun onMarshall(data: BannerDataEntity) = data.toByteArray() + + override fun onUnmarshall(bytes: ByteArray) = bytes.toParcel() +} diff --git a/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/RemoteDataSource.kt b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/RemoteDataSource.kt new file mode 100644 index 000000000..cb6def9d2 --- /dev/null +++ b/apps/wallet/data/banner/src/main/java/com/tonapps/wallet/data/banner/source/RemoteDataSource.kt @@ -0,0 +1,22 @@ +package com.tonapps.wallet.data.banner.source + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.banner.entities.BannerDataEntity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class RemoteDataSource( + private val api: API +) { + + suspend fun load(network: TonNetwork): BannerDataEntity? = withContext(Dispatchers.IO) { + try { + BannerDataEntity(api.getBanners(network)) + } catch (e: Throwable) { + FirebaseCrashlytics.getInstance().recordException(e) + null + } + } +} diff --git a/apps/wallet/data/battery/build.gradle.kts b/apps/wallet/data/battery/build.gradle.kts index 631851974..1a82f8eb3 100644 --- a/apps/wallet/data/battery/build.gradle.kts +++ b/apps/wallet/data/battery/build.gradle.kts @@ -1,21 +1,18 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.battery") -} - dependencies { implementation(libs.okhttp) + implementation(libs.koin.core) - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.network)) - implementation(project(ProjectModules.Lib.icu)) - implementation(project(ProjectModules.Lib.security)) + implementation(projects.tonapi.legacy) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.api) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.network) + implementation(projects.lib.icu) + implementation(projects.lib.security) } diff --git a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryMapper.kt b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryMapper.kt index 52ef57364..ed38fe8b9 100644 --- a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryMapper.kt +++ b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryMapper.kt @@ -21,12 +21,20 @@ object BatteryMapper { return balance.value.divide(meanFeesBigDecimal, 0, RoundingMode.UP).toInt() } + fun convertFromCharges( + charges: Int, + meanFees: String + ): Coins { + val meanFeesBigDecimal = BigDecimal(meanFees) + val amountBigDecimal = meanFeesBigDecimal.multiply(BigDecimal(charges)) + return Coins(amountBigDecimal) + } + fun calculateChargesAmount( transactionCostBigDecimal: BigDecimal, meanFees: String ): Int { val meanFeesBigDecimal = BigDecimal(meanFees) - return transactionCostBigDecimal.divide(meanFeesBigDecimal, 0, RoundingMode.HALF_UP) .toInt() } @@ -49,12 +57,15 @@ object BatteryMapper { reservedAmount: BigDecimal, meanFees: BigDecimal ): Int { + if (meanFees <= BigDecimal.ZERO || tonPriceInUsd <= Coins.ZERO) { + return 0 + } + return userProceed.toBigDecimal() .divide(tonPriceInUsd.value, 20, RoundingMode.HALF_UP) .minus(reservedAmount) .divide(meanFees, 20, RoundingMode.HALF_UP) .setScale(0, RoundingMode.FLOOR) .toInt() - } } \ No newline at end of file diff --git a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryRepository.kt b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryRepository.kt index 8f6f6bb1c..9b36e7bc9 100644 --- a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryRepository.kt +++ b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/BatteryRepository.kt @@ -1,33 +1,20 @@ package com.tonapps.wallet.data.battery import android.content.Context -import android.util.Log -import androidx.collection.ArrayMap +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.extensions.MutableEffectFlow -import com.tonapps.extensions.filterList -import com.tonapps.icu.CurrencyFormatter import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.battery.entity.BatteryConfigEntity +import com.tonapps.wallet.api.entity.EmulateWithBatteryResult import com.tonapps.wallet.data.battery.entity.BatteryBalanceEntity +import com.tonapps.wallet.data.battery.entity.BatteryConfigEntity import com.tonapps.wallet.data.battery.entity.RechargeMethodEntity import com.tonapps.wallet.data.battery.source.LocalDataSource import com.tonapps.wallet.data.battery.source.RemoteDataSource -import io.tonapi.models.MessageConsequences import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.cancellable -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.ton.api.pub.PublicKeyEd25519 @@ -47,15 +34,15 @@ class BatteryRepository( init { _balanceUpdatedFlow.tryEmit(Unit) scope.launch(Dispatchers.IO) { - getConfig(false, ignoreCache = true) + getConfig(TonNetwork.MAINNET, ignoreCache = true) } } suspend fun getRechargeMethodByJetton( - testnet: Boolean, + network: TonNetwork, jetton: String ): RechargeMethodEntity? { - val rechargeMethods = getConfig(testnet).rechargeMethods.filter { it.supportRecharge } + val rechargeMethods = getConfig(network).rechargeMethods.filter { it.supportRecharge } if (rechargeMethods.isEmpty()) { return null } @@ -68,35 +55,39 @@ class BatteryRepository( } suspend fun getConfig( - testnet: Boolean, + network: TonNetwork, ignoreCache: Boolean = false ): BatteryConfigEntity = withContext(Dispatchers.IO) { if (ignoreCache) { - fetchConfig(testnet) + fetchConfig(network) } else { - localDataSource.getConfig(testnet) ?: fetchConfig(testnet) + localDataSource.getConfig(network) ?: fetchConfig(network) } } - private suspend fun fetchConfig(testnet: Boolean): BatteryConfigEntity { - val config = remoteDataSource.fetchConfig(testnet) ?: return BatteryConfigEntity.Empty - localDataSource.setConfig(testnet, config) + private suspend fun fetchConfig(network: TonNetwork): BatteryConfigEntity { + val config = remoteDataSource.fetchConfig(network) ?: return BatteryConfigEntity.Empty + localDataSource.setConfig(network, config) return config } suspend fun getBalance( tonProofToken: String, publicKey: PublicKeyEd25519, - testnet: Boolean, + network: TonNetwork, ignoreCache: Boolean = false, ): BatteryBalanceEntity = withContext(Dispatchers.IO) { + if (network.isTetra) { + return@withContext BatteryBalanceEntity.Empty + } + val balance = if (ignoreCache) { - fetchBalance(publicKey, tonProofToken, testnet) + fetchBalance(publicKey, tonProofToken, network) } else { - localDataSource.getBalance(publicKey, testnet) ?: fetchBalance( + localDataSource.getBalance(publicKey, network) ?: fetchBalance( publicKey, tonProofToken, - testnet + network ) } balance @@ -105,11 +96,11 @@ class BatteryRepository( suspend fun getCharges( tonProofToken: String, publicKey: PublicKeyEd25519, - testnet: Boolean, + network: TonNetwork, ignoreCache: Boolean = false, ): Int = withContext(Dispatchers.IO) { - val balance = getBalance(tonProofToken, publicKey, testnet, ignoreCache) - val config = getConfig(testnet, ignoreCache) + val balance = getBalance(tonProofToken, publicKey, network, ignoreCache) + val config = getConfig(network, ignoreCache) val charges = BatteryMapper.convertToCharges(balance.balance, config.chargeCost) charges } @@ -117,11 +108,15 @@ class BatteryRepository( private suspend fun fetchBalance( publicKey: PublicKeyEd25519, tonProofToken: String, - testnet: Boolean + network: TonNetwork ): BatteryBalanceEntity { - val balance = remoteDataSource.fetchBalance(tonProofToken, testnet) + if (network.isTetra) { + return BatteryBalanceEntity.Empty + } + + val balance = remoteDataSource.fetchBalance(tonProofToken, network) ?: return BatteryBalanceEntity.Empty - localDataSource.setBalance(publicKey, testnet, balance) + localDataSource.setBalance(publicKey, network, balance) _balanceUpdatedFlow.emit(Unit) return balance } @@ -129,47 +124,47 @@ class BatteryRepository( fun refreshBalanceDelay( publicKey: PublicKeyEd25519, tonProofToken: String, - testnet: Boolean + network: TonNetwork ) { scope.launch(Dispatchers.IO) { delay(10000) - fetchBalance(publicKey, tonProofToken, testnet) + fetchBalance(publicKey, tonProofToken, network) } } suspend fun emulate( tonProofToken: String, publicKey: PublicKeyEd25519, - testnet: Boolean, + network: TonNetwork, boc: Cell, forceRelayer: Boolean = false, safeModeEnabled: Boolean, - ): Pair? = withContext(Dispatchers.IO) { + ): EmulateWithBatteryResult? = withContext(Dispatchers.IO) { val balance = getBalance( tonProofToken = tonProofToken, publicKey = publicKey, - testnet = testnet + network = network ).balance if (!forceRelayer && !balance.isPositive) { throw IllegalStateException("Zero balance") } - api.emulateWithBattery(tonProofToken, boc, testnet, safeModeEnabled) + api.emulateWithBattery(tonProofToken, boc, network, safeModeEnabled) } suspend fun getAppliedPromo( - testnet: Boolean, + network: TonNetwork, ): String? = withContext(Dispatchers.IO) { - localDataSource.getAppliedPromo(testnet) + localDataSource.getAppliedPromo(network) } suspend fun setAppliedPromo( - testnet: Boolean, + network: TonNetwork, promo: String?, ) = withContext(Dispatchers.IO) { - localDataSource.setAppliedPromo(testnet, promo) + localDataSource.setAppliedPromo(network, promo) } } \ No newline at end of file diff --git a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/BatteryConfigEntity.kt b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/BatteryConfigEntity.kt index 0174189e4..7f43b5083 100644 --- a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/BatteryConfigEntity.kt +++ b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/BatteryConfigEntity.kt @@ -23,7 +23,8 @@ data class BatteryConfigEntity( val batteryMeanPriceSwap: Int, val batteryMeanPriceJetton: Int, val batteryMeanPriceNft: Int, - val batteryMeanPriceTronUsdt: Int? = null, + val batteryMeanPriceTronUsdt: Int, + val tonMeanPriceTronUsdt: Float ) : Parcelable @IgnoredOnParcel @@ -37,7 +38,7 @@ data class BatteryConfigEntity( fundReceiver = null, rechargeMethods = emptyList(), gasProxy = emptyList(), - meanPrices = MeanPrices(0, 0, 0, null), + meanPrices = MeanPrices(0, 0, 0, 0, 0f), chargeCost = "0", reservedAmount = "0", ) diff --git a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/RechargeMethodEntity.kt b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/RechargeMethodEntity.kt index 4a05cfb8b..96339220d 100644 --- a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/RechargeMethodEntity.kt +++ b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/entity/RechargeMethodEntity.kt @@ -20,17 +20,6 @@ data class RechargeMethodEntity( val minBootstrapValue: String? = null ) : Parcelable { - private companion object { - - private fun RechargeMethodsMethodsInner.Type.toRechargeMethodType(): RechargeMethodType { - return when (this) { - RechargeMethodsMethodsInner.Type.jetton -> RechargeMethodType.JETTON - RechargeMethodsMethodsInner.Type.ton -> RechargeMethodType.TON - RechargeMethodsMethodsInner.Type.unknown -> RechargeMethodType.TON - } - } - } - constructor(method: RechargeMethodsMethodsInner) : this( type = method.type.toRechargeMethodType(), rate = method.rate, @@ -49,4 +38,14 @@ data class RechargeMethodEntity( } fun fromTon(amount: String) = fromTon(amount.toBigDecimal()) -} \ No newline at end of file + + private companion object { + + private fun RechargeMethodsMethodsInner.Type.toRechargeMethodType(): RechargeMethodType { + return when (this) { + RechargeMethodsMethodsInner.Type.jetton -> RechargeMethodType.JETTON + RechargeMethodsMethodsInner.Type.ton -> RechargeMethodType.TON + } + } + } +} diff --git a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/LocalDataSource.kt b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/LocalDataSource.kt index d50f4e333..3f4c9db43 100644 --- a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/LocalDataSource.kt +++ b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/LocalDataSource.kt @@ -2,6 +2,7 @@ package com.tonapps.wallet.data.battery.source import android.content.Context import androidx.core.content.edit +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.hex import com.tonapps.extensions.prefs import com.tonapps.security.Security @@ -23,43 +24,42 @@ internal class LocalDataSource( private val prefs = context.prefs(NAME) - fun setConfig(testnet: Boolean, entity: BatteryConfigEntity) { - configStore.setCache(configCacheKey(testnet), entity) + fun setConfig(network: TonNetwork, entity: BatteryConfigEntity) { + configStore.setCache(configCacheKey(network), entity) } - fun getConfig(testnet: Boolean): BatteryConfigEntity? { - return configStore.getCache(configCacheKey(testnet)) + fun getConfig(network: TonNetwork): BatteryConfigEntity? { + return configStore.getCache(configCacheKey(network)) } - private fun configCacheKey(testnet: Boolean): String { - return if (testnet) "testnet" else "mainnet" + private fun configCacheKey(network: TonNetwork): String { + return network.name.lowercase() } - fun setBalance(publicKey: PublicKeyEd25519, testnet: Boolean, entity: BatteryBalanceEntity) { - balance.setCache(balanceCacheKey(publicKey, testnet), entity) + fun setBalance(publicKey: PublicKeyEd25519, network: TonNetwork, entity: BatteryBalanceEntity) { + balance.setCache(balanceCacheKey(publicKey, network), entity) } - fun getBalance(publicKey: PublicKeyEd25519, testnet: Boolean): BatteryBalanceEntity? { - return balance.getCache(balanceCacheKey(publicKey, testnet)) + fun getBalance(publicKey: PublicKeyEd25519, network: TonNetwork): BatteryBalanceEntity? { + return balance.getCache(balanceCacheKey(publicKey, network)) } - private fun balanceCacheKey(publicKey: PublicKeyEd25519, testnet: Boolean): String { - val prefix = if (testnet) "testnet" else "mainnet" - return "$prefix:${publicKey.hex()}" + private fun balanceCacheKey(publicKey: PublicKeyEd25519, network: TonNetwork): String { + return "${network.name.lowercase()}:${publicKey.hex()}" } - fun getAppliedPromo(testnet: Boolean): String? { - return prefs.getString(promoKey(testnet), null) + fun getAppliedPromo(network: TonNetwork): String? { + return prefs.getString(promoKey(network), null) } - fun setAppliedPromo(testnet: Boolean, promo: String?) { + fun setAppliedPromo(network: TonNetwork, promo: String?) { prefs.edit { - putString(promoKey(testnet), promo) + putString(promoKey(network), promo) } } - private fun promoKey(testnet: Boolean): String { - return "promo_${if (testnet) "testnet" else "mainnet"}" + private fun promoKey(network: TonNetwork): String { + return "promo_${network.name.lowercase()}" } } diff --git a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/RemoteDataSource.kt b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/RemoteDataSource.kt index 91ce2aeed..b1d1ee205 100644 --- a/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/RemoteDataSource.kt +++ b/apps/wallet/data/battery/src/main/java/com/tonapps/wallet/data/battery/source/RemoteDataSource.kt @@ -1,6 +1,7 @@ package com.tonapps.wallet.data.battery.source -import android.util.Log +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.log.L import com.tonapps.icu.Coins import com.tonapps.wallet.api.API import com.tonapps.wallet.data.battery.entity.BatteryBalanceEntity @@ -16,9 +17,9 @@ internal class RemoteDataSource( suspend fun fetchBalance( tonProofToken: String, - testnet: Boolean + network: TonNetwork ): BatteryBalanceEntity? = withContext(Dispatchers.IO) { - val response = api.getBatteryBalance(tonProofToken, testnet) ?: return@withContext null + val response = api.getBatteryBalance(tonProofToken, network) ?: return@withContext null BatteryBalanceEntity( balance = Coins.of(response.balance.toBigDecimal(), 20), @@ -27,10 +28,10 @@ internal class RemoteDataSource( } suspend fun fetchConfig( - testnet: Boolean + network: TonNetwork ): BatteryConfigEntity? = withContext(Dispatchers.IO) { - val configDeferred = async { api.getBatteryConfig(testnet) } - val rechargeMethodsDeferred = async { api.getBatteryRechargeMethods(testnet) } + val configDeferred = async { api.getBatteryConfig(network) } + val rechargeMethodsDeferred = async { api.getBatteryRechargeMethods(network) } val config = configDeferred.await() ?: return@withContext null val rechargeMethods = rechargeMethodsDeferred.await() ?: return@withContext null @@ -44,7 +45,8 @@ internal class RemoteDataSource( batteryMeanPriceSwap = config.meanPrices.batteryMeanPriceSwap, batteryMeanPriceJetton = config.meanPrices.batteryMeanPriceJetton, batteryMeanPriceNft = config.meanPrices.batteryMeanPriceNft, - batteryMeanPriceTronUsdt = config.meanPrices.batteryMeanPriceTronUsdt + batteryMeanPriceTronUsdt = config.meanPrices.batteryMeanPriceTronUsdt ?: 0, + tonMeanPriceTronUsdt = config.meanPrices.tonMeanPriceTronUsdt ?: 0f ), chargeCost = config.chargeCost, reservedAmount = config.batteryReservedAmount diff --git a/apps/wallet/data/browser/build.gradle.kts b/apps/wallet/data/browser/build.gradle.kts index d4430de92..dcb62984a 100644 --- a/apps/wallet/data/browser/build.gradle.kts +++ b/apps/wallet/data/browser/build.gradle.kts @@ -1,20 +1,18 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.browser") -} - dependencies { implementation(libs.okhttp) + implementation(libs.koin.core) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.account)) + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.account) - implementation(project(ProjectModules.Lib.network)) - implementation(project(ProjectModules.Lib.extensions)) + implementation(projects.lib.blockchain) + implementation(projects.lib.network) + implementation(projects.lib.extensions) } diff --git a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/BrowserRepository.kt b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/BrowserRepository.kt index 861c54f8a..c9a73f27e 100644 --- a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/BrowserRepository.kt +++ b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/BrowserRepository.kt @@ -2,7 +2,8 @@ package com.tonapps.wallet.data.browser import android.content.Context import android.net.Uri -import android.util.Log +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.log.L import com.tonapps.extensions.toUriOrNull import com.tonapps.wallet.api.API import com.tonapps.wallet.data.browser.entities.BrowserAppEntity @@ -28,10 +29,10 @@ class BrowserRepository(context: Context, api: API) { suspend fun search( country: String, query: String, - testnet: Boolean = false, + network: TonNetwork = TonNetwork.MAINNET, locale: Locale ): List { - val data = load(country, testnet, locale) ?: return emptyList() + val data = load(country, network, locale) ?: return emptyList() val all = data.categories.map { it.apps }.flatten() return all.filter { it.name.contains(query, ignoreCase = true) || @@ -40,12 +41,12 @@ class BrowserRepository(context: Context, api: API) { }.distinctBy { it.url } } - suspend fun isTrustedApp(country: String, testnet: Boolean, locale: Locale, deeplink: Uri): Boolean { + suspend fun isTrustedApp(country: String, network: TonNetwork, locale: Locale, deeplink: Uri): Boolean { if (deeplink.host == "dapp.aeon.xyz" || deeplink.host == "tonkeeper.com" || deeplink.host?.endsWith(".tonkeeper.com") == true) { return true } val host = deeplink.host ?: return false - val apps = getApps(country, testnet, locale) + val apps = getApps(country, network, locale) for (app in apps) { if (app.useTG) { continue @@ -56,17 +57,17 @@ class BrowserRepository(context: Context, api: API) { return false } - suspend fun getApps(country: String, testnet: Boolean, locale: Locale): List { - return load(country, testnet, locale)?.categories?.map { it.apps }?.flatten() ?: emptyList() + suspend fun getApps(country: String, network: TonNetwork, locale: Locale): List { + return load(country, network, locale)?.categories?.map { it.apps }?.flatten() ?: emptyList() } - suspend fun getApp(country: String, testnet: Boolean, locale: Locale, uri: Uri): BrowserAppEntity? { + suspend fun getApp(country: String, network: TonNetwork, locale: Locale, uri: Uri): BrowserAppEntity? { val host = uri.host ?: return null val browserApp = appCacheByHost[host] if (browserApp != null) { return browserApp } - val apps = getApps(country, testnet, locale) + val apps = getApps(country, network, locale) for (app in apps) { if (app.useTG) { continue @@ -80,23 +81,23 @@ class BrowserRepository(context: Context, api: API) { fun dataFlow( country: String, - testnet: Boolean, + network: TonNetwork, locale: Locale ) = flow { loadLocal(country, locale)?.let { emit(it) } - loadRemote(country, testnet, locale)?.let { emit(it) } + loadRemote(country, network, locale)?.let { emit(it) } } - suspend fun load(country: String, testnet: Boolean, locale: Locale): BrowserDataEntity? = withContext(Dispatchers.IO) { - loadLocal(country, locale) ?: loadRemote(country, testnet, locale) + suspend fun load(country: String, network: TonNetwork, locale: Locale): BrowserDataEntity? = withContext(Dispatchers.IO) { + loadLocal(country, locale) ?: loadRemote(country, network, locale) } suspend fun loadCategories( country: String, - testnet: Boolean, + network: TonNetwork, locale: Locale ): List { - return load(country, testnet, locale)?.categories?.map { it.id } ?: emptyList() + return load(country, network, locale)?.categories?.map { it.id } ?: emptyList() } private fun loadLocal(country: String, locale: Locale): BrowserDataEntity? { @@ -106,8 +107,8 @@ class BrowserRepository(context: Context, api: API) { private fun cacheKey(country: String, locale: Locale) = "browser_data_${country}_${locale.language}" - suspend fun loadRemote(country: String, testnet: Boolean, locale: Locale): BrowserDataEntity? { - val data = remoteDataSource.load(testnet, locale) ?: return null + suspend fun loadRemote(country: String, network: TonNetwork, locale: Locale): BrowserDataEntity? { + val data = remoteDataSource.load(network, locale) ?: return null val key = cacheKey(country, locale) localDataSource.setCache(key, data) return data diff --git a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserAppEntity.kt b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserAppEntity.kt index fd5eca11f..13a5bdbcd 100644 --- a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserAppEntity.kt +++ b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserAppEntity.kt @@ -3,7 +3,7 @@ package com.tonapps.wallet.data.browser.entities import android.graphics.Color import android.net.Uri import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import androidx.core.net.toUri import com.tonapps.extensions.toUriOrNull import kotlinx.parcelize.IgnoredOnParcel diff --git a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserCategoryEntity.kt b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserCategoryEntity.kt index 646bd4673..832d3894e 100644 --- a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserCategoryEntity.kt +++ b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserCategoryEntity.kt @@ -19,7 +19,6 @@ data class BrowserCategoryEntity( ) companion object { - fun parse(array: JSONArray): List { return (0 until array.length()).map { BrowserCategoryEntity(array.getJSONObject(it)) } } diff --git a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserDataEntity.kt b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserDataEntity.kt index 71577a4dc..90a84eb71 100644 --- a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserDataEntity.kt +++ b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/entities/BrowserDataEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.browser.entities import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import kotlinx.parcelize.Parcelize import org.json.JSONObject diff --git a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/source/RemoteDataSource.kt b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/source/RemoteDataSource.kt index 3d09b8b9c..f52624a5c 100644 --- a/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/source/RemoteDataSource.kt +++ b/apps/wallet/data/browser/src/main/java/com/tonapps/wallet/data/browser/source/RemoteDataSource.kt @@ -1,6 +1,7 @@ package com.tonapps.wallet.data.browser.source -import android.util.Log +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.log.L import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.wallet.api.API import com.tonapps.wallet.data.browser.entities.BrowserDataEntity @@ -12,9 +13,9 @@ internal class RemoteDataSource( private val api: API ) { - suspend fun load(testnet: Boolean, locale: Locale): BrowserDataEntity? = withContext(Dispatchers.IO) { + suspend fun load(network: TonNetwork, locale: Locale): BrowserDataEntity? = withContext(Dispatchers.IO) { try { - BrowserDataEntity(api.getBrowserApps(testnet, locale)) + BrowserDataEntity(api.getBrowserApps(network, locale)) } catch (e: Throwable) { FirebaseCrashlytics.getInstance().recordException(e) null diff --git a/apps/wallet/data/collectibles/build.gradle.kts b/apps/wallet/data/collectibles/build.gradle.kts index aa2a5a682..65ea2c33f 100644 --- a/apps/wallet/data/collectibles/build.gradle.kts +++ b/apps/wallet/data/collectibles/build.gradle.kts @@ -1,17 +1,15 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.collectibles") -} - dependencies { - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.sqlite)) + implementation(libs.koin.core) + + implementation(projects.tonapi.legacy) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.api) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.sqlite) } diff --git a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/CollectiblesRepository.kt b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/CollectiblesRepository.kt index bb9c01368..a3bfc936d 100644 --- a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/CollectiblesRepository.kt +++ b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/CollectiblesRepository.kt @@ -1,21 +1,18 @@ package com.tonapps.wallet.data.collectibles import android.content.Context -import android.util.Log +import com.tonapps.blockchain.ton.TonNetwork import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.withRetry import com.tonapps.wallet.data.collectibles.entities.DnsExpiringEntity import com.tonapps.wallet.data.collectibles.entities.NftEntity import com.tonapps.wallet.data.collectibles.entities.NftListResult import com.tonapps.wallet.data.collectibles.source.LocalDataSource import io.extensions.renderType import io.tonapi.models.TrustType -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext class CollectiblesRepository( private val context: Context, @@ -26,49 +23,51 @@ class CollectiblesRepository( LocalDataSource(context) } - suspend fun getDnsExpiring(accountId: String, testnet: Boolean, period: Int) = api.getDnsExpiring(accountId, testnet, period).map { model -> - DnsExpiringEntity( - expiringAt = model.expiringAt, - name = model.name, - dnsItem = model.dnsItem?.let { NftEntity(it, testnet) } - ) - }.sortedBy { it.daysUntilExpiration } + suspend fun getDnsExpiring(accountId: String, network: TonNetwork, period: Int) = run { + api.getDnsExpiring(accountId, network, period).map { model -> + DnsExpiringEntity( + expiringAt = model.expiringAt, + name = model.name, + dnsItem = model.dnsItem?.let { NftEntity(it, network) } + ) + }.sortedBy { it.daysUntilExpiration } + } - suspend fun getDnsSoonExpiring(accountId: String, testnet: Boolean, period: Int = 30) = getDnsExpiring(accountId, testnet, period) + suspend fun getDnsSoonExpiring(accountId: String, network: TonNetwork, period: Int = 30) = getDnsExpiring(accountId, network, period) suspend fun getDnsNftExpiring( accountId: String, - testnet: Boolean, + network: TonNetwork, nftAddress: String - ) = getDnsExpiring(accountId, testnet, 366).firstOrNull { + ) = getDnsExpiring(accountId, network, 366).firstOrNull { it.dnsItem?.address?.equalsAddress(nftAddress) == true } - fun getNft(accountId: String, testnet: Boolean, address: String): NftEntity? { - val nft = localDataSource.getSingle(accountId, testnet, address) + fun getNft(accountId: String, network: TonNetwork, address: String): NftEntity? { + val nft = localDataSource.getSingle(accountId, network.isTestnet, address) if (nft != null) { return nft } - return api.getNft(address, testnet)?.let { NftEntity(it, testnet) } + return api.getNft(address, network)?.let { NftEntity(it, network) } } - fun get(address: String, testnet: Boolean): List? { - val local = localDataSource.get(address, testnet) + fun get(address: String, network: TonNetwork): List? { + val local = localDataSource.get(address, network.isTestnet) if (local.isEmpty()) { - return getRemoteNftItems(address, testnet) + return getRemoteNftItems(address, network) } return local } - fun getFlow(address: String, testnet: Boolean, isOnline: Boolean) = flow { + fun getFlow(address: String, network: TonNetwork, isOnline: Boolean) = flow { try { - val local = getLocalNftItems(address, testnet) + val local = getLocalNftItems(address, network) if (local.isNotEmpty()) { emit(NftListResult(cache = true, list = local)) } if (isOnline) { - val remote = getRemoteNftItems(address, testnet) ?: return@flow + val remote = getRemoteNftItems(address, network) ?: return@flow emit(NftListResult(cache = false, list = remote)) } } catch (e: Throwable) { @@ -78,21 +77,21 @@ class CollectiblesRepository( private fun getLocalNftItems( address: String, - testnet: Boolean + network: TonNetwork ): List { - return localDataSource.get(address, testnet) + return localDataSource.get(address, network.isTestnet) } private fun getRemoteNftItems( address: String, - testnet: Boolean + network: TonNetwork ): List? { - val nftItems = api.getNftItems(address, testnet) ?: return null + val nftItems = api.getNftItems(address, network) ?: return null val items = nftItems.filter { it.trust != TrustType.blacklist && it.renderType != "hidden" - }.map { NftEntity(it, testnet) } + }.map { NftEntity(it, network) } - localDataSource.save(address, testnet, items.toList()) + localDataSource.save(address, network.isTestnet, items.toList()) return items } -} \ No newline at end of file +} diff --git a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftEntity.kt b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftEntity.kt index 6e3362af0..9e01bfd40 100644 --- a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftEntity.kt +++ b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftEntity.kt @@ -3,6 +3,7 @@ package com.tonapps.wallet.data.collectibles.entities import android.net.Uri import android.os.Parcelable import androidx.core.net.toUri +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.blockchain.ton.extensions.toUserFriendly import com.tonapps.wallet.api.entity.AccountEntity @@ -18,7 +19,7 @@ data class NftEntity( val collection: NftCollectionEntity?, val metadata: NftMetadataEntity, val previews: List, - val testnet: Boolean, + val network: TonNetwork, val verified: Boolean, val inSale: Boolean, val dns: String?, @@ -29,10 +30,10 @@ data class NftEntity( get() = collection?.address ?: address val id: String - get() = if (testnet) "testnet:$address" else address + get() = if (network.isTestnet) "testnet:$address" else address val userFriendlyAddress: String - get() = address.toUserFriendly(wallet = false, testnet = testnet) + get() = address.toUserFriendly(wallet = false, testnet = network.isTestnet) val name: String get() = metadata.name ?: "" @@ -93,6 +94,19 @@ data class NftEntity( return address.equalsAddress("0:80d78a35f955a14b679faa887ff4cd5bfc0f43b4a4eea2a7e6927f3701b273c2") } + constructor(item: NftItem, network: TonNetwork) : this( + address = item.address, + owner = item.owner?.let { AccountEntity(it, network) }, + collection = item.collection?.let { NftCollectionEntity(it) }, + metadata = NftMetadataEntity(item.metadata), + previews = item.previews?.map { NftPreviewEntity(it) } ?: emptyList(), + network = network, + verified = item.approvedBy.isNotEmpty(), + inSale = item.sale != null, + dns = item.dns, + trust = Trust(item.trust.value), + ) + private fun getImage(minSize: Int, maxSize: Int): NftPreviewEntity? { return previews.find { if (minSize > it.width || minSize > it.height) { @@ -108,17 +122,4 @@ data class NftEntity( private fun getImageUri(minSize: Int, maxSize: Int): Uri? { return getImage(minSize, maxSize)?.url?.let { Uri.parse(it) } } - - constructor(item: NftItem, testnet: Boolean) : this( - address = item.address, - owner = item.owner?.let { AccountEntity(it, testnet) }, - collection = item.collection?.let { NftCollectionEntity(it) }, - metadata = NftMetadataEntity(item.metadata), - previews = item.previews?.map { NftPreviewEntity(it) } ?: emptyList(), - testnet = testnet, - verified = item.approvedBy.isNotEmpty(), - inSale = item.sale != null, - dns = item.dns, - trust = Trust(item.trust.value), - ) -} \ No newline at end of file +} diff --git a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftMetadataEntity.kt b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftMetadataEntity.kt index 6e3eaff71..093e2e78a 100644 --- a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftMetadataEntity.kt +++ b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftMetadataEntity.kt @@ -2,7 +2,7 @@ package com.tonapps.wallet.data.collectibles.entities import android.os.Parcelable import android.util.Base64 -import android.util.Log +import com.tonapps.log.L import io.JsonAny import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize diff --git a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftPreviewEntity.kt b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftPreviewEntity.kt index 5f92abbf3..6f05a3ebf 100644 --- a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftPreviewEntity.kt +++ b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/entities/NftPreviewEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.collectibles.entities import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import io.tonapi.models.ImagePreview import kotlinx.parcelize.Parcelize diff --git a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/source/LocalDataSource.kt b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/source/LocalDataSource.kt index 54a1bdc68..57bff2061 100644 --- a/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/source/LocalDataSource.kt +++ b/apps/wallet/data/collectibles/src/main/java/com/tonapps/wallet/data/collectibles/source/LocalDataSource.kt @@ -3,7 +3,7 @@ package com.tonapps.wallet.data.collectibles.source import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toParcel import com.tonapps.sqlite.SQLiteHelper diff --git a/apps/wallet/data/contacts/build.gradle.kts b/apps/wallet/data/contacts/build.gradle.kts index 976af010e..699bad4ec 100644 --- a/apps/wallet/data/contacts/build.gradle.kts +++ b/apps/wallet/data/contacts/build.gradle.kts @@ -1,15 +1,14 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.contacts") -} - dependencies { - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.sqlite)) - implementation(project(ProjectModules.Wallet.Data.rn)) + implementation(libs.koin.core) + + implementation(projects.apps.wallet.data.core) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.sqlite) + implementation(projects.apps.wallet.data.rn) } diff --git a/apps/wallet/data/contacts/src/main/java/com/tonapps/wallet/data/contacts/ContactsRepository.kt b/apps/wallet/data/contacts/src/main/java/com/tonapps/wallet/data/contacts/ContactsRepository.kt index 78b3a9c16..0d8a4e93d 100644 --- a/apps/wallet/data/contacts/src/main/java/com/tonapps/wallet/data/contacts/ContactsRepository.kt +++ b/apps/wallet/data/contacts/src/main/java/com/tonapps/wallet/data/contacts/ContactsRepository.kt @@ -1,6 +1,7 @@ package com.tonapps.wallet.data.contacts import android.content.Context +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.MutableEffectFlow import com.tonapps.wallet.data.contacts.entities.ContactEntity import com.tonapps.wallet.data.contacts.source.DatabaseSource @@ -11,7 +12,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -51,15 +51,16 @@ class ContactsRepository( } } - fun isHidden(accountId: String, testnet: Boolean) = database.isHidden(accountId, testnet) + fun isHidden(accountId: String, network: TonNetwork) = + database.isHidden(accountId, network.isTestnet) - fun hide(accountId: String, testnet: Boolean) { - database.setHidden(accountId, testnet, true) + fun hide(accountId: String, network: TonNetwork) { + database.setHidden(accountId, network.isTestnet, true) _hiddenFlow.tryEmit(Unit) } - suspend fun add(name: String, address: String, testnet: Boolean): ContactEntity = withContext(Dispatchers.IO) { - val contact = database.addContact(name, address, testnet) + suspend fun add(name: String, address: String, network: TonNetwork): ContactEntity = withContext(Dispatchers.IO) { + val contact = database.addContact(name, address, network.isTestnet) _contactsFlow.value = _contactsFlow.value.orEmpty().toMutableList().apply { add(contact) } @@ -81,4 +82,4 @@ class ContactsRepository( oldContacts[index] = oldContacts[index].copy(name = name) _contactsFlow.value = oldContacts } -} \ No newline at end of file +} diff --git a/apps/wallet/data/core/build.gradle.kts b/apps/wallet/data/core/build.gradle.kts index 778852732..1ea899657 100644 --- a/apps/wallet/data/core/build.gradle.kts +++ b/apps/wallet/data/core/build.gradle.kts @@ -1,25 +1,14 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") + id("target.android.library") id("kotlin-parcelize") -} - -android { - namespace = Build.namespacePrefix("wallet.data.core") - compileSdk = Build.compileSdkVersion - - defaultConfig { - minSdk = Build.minSdkVersion - consumerProguardFiles("consumer-rules.pro") - } + alias(libs.plugins.kotlin.serialization) } dependencies { api(platform(libs.firebase.bom)) api(libs.firebase.crashlytics) + implementation(libs.kotlinx.serialization.core) implementation(libs.ton.tvm) implementation(libs.ton.crypto) implementation(libs.ton.tlb) @@ -27,14 +16,12 @@ dependencies { implementation(libs.ton.tonapiTl) implementation(libs.ton.contract) implementation(libs.koin.core) - implementation(libs.androidX.biometric) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.sqlite)) - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.UIKit.flag)) + implementation(libs.androidx.biometric) + implementation(projects.apps.wallet.api) + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) + implementation(projects.lib.sqlite) + implementation(projects.lib.icu) + implementation(projects.tonapi.legacy) + implementation(projects.ui.uikit.flag) } - - - diff --git a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/BlobDataSource.kt b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/BlobDataSource.kt index 88a9c5fa2..6033c8322 100644 --- a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/BlobDataSource.kt +++ b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/BlobDataSource.kt @@ -19,7 +19,6 @@ abstract class BlobDataSource( ) { companion object { - inline fun simple( context: Context, path: String, @@ -84,15 +83,18 @@ abstract class BlobDataSource( } private fun diskFile(key: String): File { - return context.cacheFolder(path).file("${key}.dat") + return context.cacheFolder(path) + .file("${key}.dat") } private fun readDiskCache(key: String): ByteArray? { val file = diskFile(key) + val lastModified = file.lastModified() if (file.length() == 0L || 0L >= lastModified) { return null } + val diff = System.currentTimeMillis() - lastModified if (diff > timeout) { file.delete() diff --git a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/Extensions.kt b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/Extensions.kt index f9ac18470..42d2553e0 100644 --- a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/Extensions.kt +++ b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/Extensions.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.biometric.BiometricManager import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.blockchain.ton.extensions.equalsAddress -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency fun List.query(value: String) = firstOrNull { it.address.equalsAddress(value) diff --git a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/ScreenCacheSource.kt b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/ScreenCacheSource.kt index 340954125..270847836 100644 --- a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/ScreenCacheSource.kt +++ b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/ScreenCacheSource.kt @@ -3,7 +3,7 @@ package com.tonapps.wallet.data.core import android.content.Context import android.os.Parcel import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.extensions.cacheFolder import com.tonapps.extensions.file diff --git a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/currency/WalletCurrency.kt b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/currency/WalletCurrency.kt deleted file mode 100644 index 9c0f194fe..000000000 --- a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/currency/WalletCurrency.kt +++ /dev/null @@ -1,567 +0,0 @@ -package com.tonapps.wallet.data.core.currency - -import android.net.Uri -import android.os.Parcelable -import android.util.Log -import androidx.annotation.DrawableRes -import com.tonapps.extensions.toUriOrNull -import com.tonapps.uikit.flag.getFlagDrawable -import com.tonapps.wallet.api.R -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize -import androidx.core.net.toUri - -@Parcelize -data class WalletCurrency( - val code: String, - val title: String, - val alias: String = "", - val chain: Chain, - val iconUrl: String? = null -): Parcelable { - - sealed class Chain( - open val decimals: Int, - val name: String - ): Parcelable { - - @Parcelize - data class FIAT( - val countryCode: String - ): Chain(6, "FIAT") - - @Parcelize - data class TON( - val address: String = TON_KEY, - override val decimals: Int = 9, - ): Chain(decimals, "TON") - - @Parcelize - data class TRON( - val address: String = "TRON", - override val decimals: Int = 6, - ): Chain(decimals, "TRON") - - @Parcelize - data object BTC: Chain(8, "BTC") - - @Parcelize - data class ETC( - val address: String = "ETC", - override val decimals: Int = 12, - ): Chain(decimals, "ETC") - - @Parcelize - data class BNB( - val address: String = "BNB", - override val decimals: Int = 18, - ): Chain(decimals, "BNB") - - @Parcelize - data class Solana( - val address: String = "SOL", - override val decimals: Int = 9, - ): Chain(decimals, "SOL") - - @Parcelize - data class Unknown( - val type: String, - val address: String, - override val decimals: Int = 6, - ): Chain(decimals, type.uppercase()) - } - - companion object { - - private val popularCurrencies = listOf( - "USD", - "EUR", - "GBP", - "JPY", - "CHF", - "CNY", - "INR", - "UAH", - "RUB", - "AUD", - "CAD", - "HKD", - "SGD", - "BTC", - "USDT", - "ETH", - ) - - val FIAT = listOf( - "USD", // United States Dollar - "EUR", // Euro - "RUB", // Russian Ruble - "AED", // United Arab Emirates Dirham - "UAH", // Ukrainian Hryvnia - "KZT", // Kazakhstani Tenge - "UZS", // Uzbekistani sum - "GBP", // Great Britain Pound - "CHF", // Swiss Franc - "CNY", // China Yuan - "GEL", // Georgian Lari - "KRW", // South Korean Won - "IDR", // Indonesian Rupiah - "INR", // Indian Rupee - "PKR", // Pakistani Rupee - "JPY", // Japanese Yen - "CAD", // Canadian Dollar - "ARS", // Argentine Peso - "BYN", // Belarusian Ruble - "COP", // Colombian Peso - "ETB", // Ethiopian Birr - "ILS", // Israeli Shekel - "KES", // Kenyan Shilling - "NGN", // Nigerian Naira - "UGX", // Ugandan Shilling - "VES", // Venezuelan Bolivar - "ZAR", // South African Rand - "TRY", // Turkish Lira - "THB", // Thai Baht - "VND", // Vietnamese Dong - "BRL", // Brazilian Real - "BDT", // Bangladeshi Taka - "AUD", // Australian Dollar - "HKD", // Hong Kong Dollar - "SGD", // Singapore Dollar - "ISK", // Icelandic Króna - "PHP", // Philippine Peso - "FJD", // Fijian Dollar - "AOA", // Angolan Kwanza - "MGA", // Malagasy Ariary - "FKP", // Falkland Islands Pound - "BSD", // Bahamian Dollar - "PGK", // Papua New Guinean Kina - "TOP", // Tongan Paʻanga - "XCD", // East Caribbean Dollar - "MDL", // Moldovan Leu - "PEN", // Peruvian Sol - "BHD", // Bahraini Dinar - "HNL", // Honduran Lempira - "GTQ", // Guatemalan Quetzal - "GHS", // Gibraltar Pound - "BZD", // Belize Dollar - "SBD", // Solomon Islands Dollar - "MRU", // Mauritanian Ouguiya - "OMR", // Omani Rial - "KGS", // Kyrgyzstani Som - "PLN", // Polish Zloty - "CLP", // Chilean Peso - "SCR", // Seychellois Rupee - "DOP", // Dominican Peso - "LKR", // Sri Lankan Rupee - "QAR", // Qatari Rial - "KYD", // Cayman Islands Dollar - "RWF", // Rwandan Franc - "AZN", // Azerbaijani Manat - "CZK", // Czech Koruna - "CRC", // Costa Rican Colón - "BGN", // Bulgarian Lev - "BMD", // Bermudian Dollar - "DKK", // Danish Krone - "UYU", // Uruguayan Peso - "DZD", // Algerian Dinar - "BAM", // Bosnian Convertible - "STN", // São Tomé and Príncipe Dobra - "TMT", // Turkmenistani Manat - "KHR", // Cambodian Riel - "BND", // Brunei Dollar - "MXN", // Mexican Peso - "NZD", // New Zealand Dollar - "MKD", // Macedonian Denar - "JOD", // Jordanian Dinar - "MWK", // Malawian Kwacha - "TTD", // Trinidad and Tobago Dollar - "KMF", // Comorian Franc - "SRD", // Surinamese Dollar - "TJS", // Tajikistani Somoni - "CVE", // Cape Verdean Escudo - "HUF", // Hungarian Forint - "PYG", // Paraguayan Guarani - "SEK", // Swedish Krona - "ANG", // Netherlands Antillean Guilder - "MYR", // Malaysian Ringgit - "TWD", // New Taiwan Dollar - "SZL", // Swazi Lilangeni - "RSD", // Serbian Dinar - "DJF", // Djiboutian Franc - "AMD", // Armenian Dram - "PAB", // Panamanian Balboa - "ZAR", // South African Rand - "KWD", // Kuwaiti Dinar - "RON", // Romanian Leu - "EGP", // Egyptian Pound - "NOK" // Norwegian Krone - ) - - const val USDT_KEY = "USDT" - const val USDE_KEY = "USDE" - const val TS_USDE_KEY = "TS_USDE" - const val TON_KEY = "TON" - const val BTC_KEY = "BTC" - const val ETH_KEY = "ETH" - - private val USDT_TRON_ADDRESS = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" - val USDT_TON_ADDRESS = "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe" - private val USDT_ETH_ADDRESS = "0xdac17f958d2ee523a2206206994597c13d831ec7" - - val USDE_TON_ETHENA_ADDRESS = "0:086fa2a675f74347b08dd4606a549b8fdb98829cb282bc1949d3b12fbaed9dcc" - val TS_USDE_TON_ETHENA_ADDRESS = "0:d0e545323c7acb7102653c073377f7e3c67f122eb94d430a250739f109d4a57d" - - val USD = WalletCurrency( - code = "USD", - title = CurrencyCountries.getCurrencyTitle("USD"), - chain = Chain.FIAT("US") - ) - - val EUR = WalletCurrency( - code = "EUR", - title = CurrencyCountries.getCurrencyTitle("EUR"), - chain = Chain.FIAT("EU") - ) - - val DEFAULT = USD - - val TON = WalletCurrency( - code = TON_KEY, - title = "Toncoin", - chain = Chain.TON() - ) - - val BTC = WalletCurrency( - code = BTC_KEY, - title = "Bitcoin", - chain = Chain.BTC - ) - - val ETH = WalletCurrency( - code = ETH_KEY, - title = "Ethereum", - chain = Chain.ETC() - ) - - fun simple(code: String, decimals: Int, name: String, imageUrl: String): WalletCurrency { - val chain = Chain.Unknown(name, code, decimals) - return WalletCurrency( - code = code, - title = name, - chain = chain, - iconUrl = imageUrl - ) - } - - fun unknownChain( - type: String = "unknown", - address: String = "unknown" - ) = Chain.Unknown(type, address) - - fun unknown( - code: String = "unknown", - name: String = "unknown", - imageUrl: String? = null, - chain: Chain.Unknown = unknownChain() - ): WalletCurrency { - return WalletCurrency( - code = code, - title = name, - chain = chain, - iconUrl = imageUrl - ) - } - - fun createChain(type: String, address: String): Chain { - return when (type) { - "jetton" -> Chain.TON(address) - "erc-20" -> Chain.ETC(address) - "bep-20" -> Chain.BNB(address) - "spl" -> Chain.Solana(address) - else -> Chain.Unknown(type, address) - } - } - - private fun createAlias(chainCode: String, tokenCode: String): String { - if (chainCode.equals(tokenCode, ignoreCase = true) || tokenCode.isBlank()) { - return "" - } - return "${chainCode}_${tokenCode}" - } - - val USDE_TON_ETHENA = WalletCurrency( - code = USDE_KEY, - alias = createAlias(TON_KEY, USDE_KEY), - title = "Ethena USDe", - chain = Chain.TON(USDE_TON_ETHENA_ADDRESS, 6) - ) - - val TS_USDE_TON_ETHENA = WalletCurrency( - code = TS_USDE_KEY, - alias = createAlias(TON_KEY, TS_USDE_KEY), - title = "Staked USDe", - chain = Chain.TON(TS_USDE_TON_ETHENA_ADDRESS, 6) - ) - - val USDT_TRON = WalletCurrency( - code = USDT_KEY, - alias = createAlias("TRON", USDT_KEY), - title = "Tether", - chain = Chain.TRON(USDT_TRON_ADDRESS, 6) - ) - - val USDT_TON = WalletCurrency( - code = USDT_KEY, - alias = createAlias(TON_KEY, USDT_KEY), - title = "Tether", - chain = Chain.TON(USDT_TON_ADDRESS, 6) - ) - - val USDT_SPL = WalletCurrency( - code = USDT_KEY, - alias = createAlias("SPL", USDT_KEY), - title = "Tether", - chain = Chain.Solana("Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", 6) - ) - - val USDT_ETH = WalletCurrency( - code = USDT_KEY, - alias = createAlias(ETH_KEY, USDT_KEY), - title = "Tether", - chain = Chain.ETC(USDT_ETH_ADDRESS, 6) - ) - - val USDT_BEP20 = WalletCurrency( - code = USDT_KEY, - alias = createAlias("BEP20", USDT_KEY), - title = "Tether", - chain = Chain.BNB("0x55d398326f99059fF775485246999027B3197955", 6) - ) - - val USDT_AVALANCHE = WalletCurrency( - code = USDT_KEY, - alias = createAlias("AVALANCHE", USDT_KEY), - title = "Tether", - chain = Chain.Unknown("AVALANCHE", "0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7", 6) - ) - - val USDT_ARBITRUM = WalletCurrency( - code = USDT_KEY, - alias = createAlias("ARBITRUM", USDT_KEY), - title = "Tether", - chain = Chain.Unknown("ARBITRUM", "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", 6) - ) - - val ALL = FIAT - - fun sort(value: List): List { - return value.sortedWith(compareBy { currency -> - val index = popularCurrencies.indexOf(currency.code) - if (index >= 0) index else Int.MAX_VALUE - }) - } - - @DrawableRes - fun getDrawableRes(currency: WalletCurrency): Int? { - val chan = currency.chain - if (chan is Chain.FIAT) { - return getFlagDrawable(chan.countryCode) - } else if (currency == USDE_TON_ETHENA) { - return R.drawable.ic_udse_ethena_with_bg - } else if (currency.code.equals(USDT_KEY, ignoreCase = true)) { - return R.drawable.ic_usdt_with_bg - } else if (currency == TON) { - return R.drawable.ic_ton_with_bg - } else if (currency == BTC) { - return R.drawable.ic_btc_with_bg - } else if (currency == ETH) { - return R.drawable.ic_eth_with_bg - } - return null - } - - fun ofOrDefault(code: String?): WalletCurrency { - return of(code) ?: DEFAULT - } - - fun isValid(code: String): Boolean { - return of(code) != null - } - - fun of(code: String?): WalletCurrency? { - if (code == "US") { - return of("USD") - } - if (code.isNullOrBlank()) { - return null - } else if (code in FIAT) { - return WalletCurrency( - code = code, - title = CurrencyCountries.getCurrencyTitle(code), - chain = Chain.FIAT(CurrencyCountries.getCountryCode(code)) - ) - } else if (code.equals(TON.code, ignoreCase = true)) { - return TON - } else if (code.equals(BTC.code, ignoreCase = true)) { - return BTC - } else if (code.equals(ETH.code, ignoreCase = true)) { - return ETH - } else if (USDT_TRON.equalsByCode(code)) { - return USDT_TRON - } else if (USDT_TON.equalsByCode(code)) { - return USDT_TON - } else if (USDT_ETH.equalsByCode(code)) { - return USDT_ETH - } - return null - } - } - - @IgnoredOnParcel - val fiat: Boolean - get() = chain is Chain.FIAT - - @IgnoredOnParcel - val isUSDT: Boolean - get() = code.uppercase().replace("USD₮", USDT_KEY).equals(USDT_KEY, ignoreCase = true) - - @IgnoredOnParcel - val isTONChain: Boolean - get() = chain is Chain.TON - - @IgnoredOnParcel - val isTronChain: Boolean - get() = chain is Chain.TRON - - @IgnoredOnParcel - val isJetton: Boolean - get() = if (isTONChain && code == TON_KEY) { - false - } else if (isTONChain) { - true - } else { - false - } - - @IgnoredOnParcel - val decimals: Int - get() = chain.decimals - - @IgnoredOnParcel - val drawableRes: Int? by lazy { - getDrawableRes(this) - } - - @IgnoredOnParcel - val isCustom: Boolean - get() = !iconUrl.isNullOrBlank() - - @IgnoredOnParcel - val iconUri: Uri? - get() = iconUrl?.toUriOrNull() ?: drawableRes?.let { - "res:/$it".toUri() - } - - @IgnoredOnParcel - val chainName: String? by lazy { - if ((!isUSDT && isTONChain) || fiat) { - null - } else { - chain.name.uppercase().replace("TRON", "TRC20") - } - } - - @IgnoredOnParcel - val address: String by lazy { - when (chain) { - is Chain.TON -> chain.address - is Chain.TRON -> chain.address - is Chain.ETC -> chain.address - else -> code - } - } - - @IgnoredOnParcel - val symbol: String by lazy { - /*if (chain is Chain.FIAT) { - code - } else if (chain is Chain.TON) { - chain.address - } else { - chain.name // code - }*/ - code - } - - @IgnoredOnParcel - val key: String by lazy { - if (fiat) { - "fiat:$code" - } else if (isUSDT) { - "stablecoin:$code" - } else if (isTONChain && code == TON_KEY) { - "crypto:TON" - } else if (isTONChain) { - "crypto:TON:$address" - } else { - "crypto:$code" - } - } - - - @IgnoredOnParcel - val tokenQuery: String by lazy { - if (isTONChain) address else code - } - - override fun equals(other: Any?): Boolean { - val currency = other as? WalletCurrency ?: return false - if (!code.equals(currency.code, true)) { - return false - } - return chain.name.equals(currency.chain.name, true) - } - - fun containsQuery(query: String): Boolean { - return code.contains(query, ignoreCase = true) || - title.contains(query, ignoreCase = true) || - alias.contains(query, ignoreCase = true) - } - - fun equalsByCode(other: String): Boolean { - if (other.equals(code, ignoreCase = true) || (alias.isNotBlank() && alias.equals(other, ignoreCase = true))) { - return true - } - if (chain is Chain.TON && other.equals(chain.address, ignoreCase = true)) { - return true - } - if (chain is Chain.TRON && other.equals(chain.address, ignoreCase = true)) { - return true - } - if (chain is Chain.ETC && other.equals(chain.address, ignoreCase = true)) { - return true - } - return false - } - - override fun hashCode(): Int { - var result = code.hashCode() - result = 31 * result + title.hashCode() - result = 31 * result + alias.hashCode() - result = 31 * result + chain.hashCode() - result = 31 * result + (iconUrl?.hashCode() ?: 0) - result = 31 * result + fiat.hashCode() - result = 31 * result + isUSDT.hashCode() - result = 31 * result + isTONChain.hashCode() - result = 31 * result + isTronChain.hashCode() - result = 31 * result + decimals - result = 31 * result + (drawableRes ?: 0) - result = 31 * result + isCustom.hashCode() - result = 31 * result + (iconUri?.hashCode() ?: 0) - result = 31 * result + (chainName?.hashCode() ?: 0) - result = 31 * result + address.hashCode() - result = 31 * result + symbol.hashCode() - return result - } -} diff --git a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/entity/SignRequestEntity.kt b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/entity/SignRequestEntity.kt index ac2f81349..86bd450df 100644 --- a/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/entity/SignRequestEntity.kt +++ b/apps/wallet/data/core/src/main/java/com/tonapps/wallet/data/core/entity/SignRequestEntity.kt @@ -2,21 +2,15 @@ package com.tonapps.wallet.data.core.entity import android.net.Uri import android.os.Parcelable -import android.util.Log import com.tonapps.blockchain.ton.TonNetwork -import com.tonapps.blockchain.ton.extensions.isValidTonAddress import com.tonapps.blockchain.ton.extensions.toAccountId import com.tonapps.extensions.currentTimeSeconds import com.tonapps.extensions.optStringCompatJS -import kotlinx.datetime.Clock import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.json.JSONArray import org.json.JSONObject import org.ton.block.AddrStd -import org.ton.block.StateInit -import org.ton.tlb.CellRef -import kotlin.time.Duration.Companion.seconds @Parcelize data class SignRequestEntity( @@ -42,7 +36,7 @@ data class SignRequestEntity( get() = messagesVariants?.battery.isNullOrEmpty().not() val isTestnet: Boolean - get() = network == TonNetwork.TESTNET + get() = network.isTestnet val targetAddressValue: String get() = messages.first().addressValue @@ -155,4 +149,4 @@ data class SignRequestEntity( } } } -} \ No newline at end of file +} diff --git a/apps/wallet/data/dapps/build.gradle.kts b/apps/wallet/data/dapps/build.gradle.kts index cd0ae5059..e7cb95893 100644 --- a/apps/wallet/data/dapps/build.gradle.kts +++ b/apps/wallet/data/dapps/build.gradle.kts @@ -1,13 +1,8 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.dapps") -} - - dependencies { implementation(libs.ton.tvm) implementation(libs.ton.crypto) @@ -15,13 +10,15 @@ dependencies { implementation(libs.ton.blockTlb) implementation(libs.ton.tonapiTl) implementation(libs.ton.contract) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.rn)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.sqlite)) - implementation(project(ProjectModules.Lib.security)) - implementation(project(ProjectModules.Lib.network)) - implementation(project(ProjectModules.Lib.base64)) + implementation(libs.koin.core) + + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.rn) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.sqlite) + implementation(projects.lib.security) + implementation(projects.lib.network) + implementation(projects.lib.base64) } \ No newline at end of file diff --git a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/DAppsRepository.kt b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/DAppsRepository.kt index 13b7d7eac..de2b22351 100644 --- a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/DAppsRepository.kt +++ b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/DAppsRepository.kt @@ -2,7 +2,7 @@ package com.tonapps.wallet.data.dapps import android.content.Context import android.net.Uri -import android.util.Log +import com.tonapps.blockchain.ton.TonNetwork import androidx.collection.ArrayMap import androidx.core.net.toUri import com.google.firebase.crashlytics.FirebaseCrashlytics @@ -22,16 +22,13 @@ import com.tonapps.wallet.data.rn.data.RNTC import com.tonapps.wallet.data.rn.data.RNTCApp import com.tonapps.wallet.data.rn.data.RNTCApps import com.tonapps.wallet.data.rn.data.RNTCConnection -import com.tonapps.wallet.data.rn.data.RNTCKeyPair import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -39,7 +36,6 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.json.JSONObject class DAppsRepository( context: Context, @@ -159,10 +155,10 @@ class DAppsRepository( suspend fun getConnections( accountId: String, - testnet: Boolean + network: TonNetwork ): ArrayMap> { val connections = (_connectionsFlow.value ?: emptyList()).filter { - it.accountId == accountId && it.testnet == testnet + it.accountId == accountId && it.network == network } val map = connections.groupBy { it.appUrl.withoutQuery } val apps = getApps(map.keys.toList()) @@ -185,16 +181,16 @@ class DAppsRepository( database.setLastAppRequestId(clientId, requestId) } - fun isPushEnabled(accountId: String, testnet: Boolean, appUrl: Uri): Boolean { - return database.isPushEnabled(accountId, testnet, appUrl.withoutQuery) + fun isPushEnabled(accountId: String, network: TonNetwork, appUrl: Uri): Boolean { + return database.isPushEnabled(accountId, network, appUrl.withoutQuery) } - fun setPushEnabled(accountId: String, testnet: Boolean, appUrl: Uri, enabled: Boolean): List { + fun setPushEnabled(accountId: String, network: TonNetwork, appUrl: Uri, enabled: Boolean): List { val otherConnections = mutableListOf() val accountConnections = mutableListOf() for (connection in (_connectionsFlow.value ?: return emptyList())) { - if (connection.accountId == accountId && connection.testnet == testnet && connection.appUrl.withoutQuery == appUrl.withoutQuery) { + if (connection.accountId == accountId && connection.network == network && connection.appUrl.withoutQuery == appUrl.withoutQuery) { accountConnections.add(connection.copy(pushEnabled = enabled)) } else { otherConnections.add(connection.copy()) @@ -205,7 +201,7 @@ class DAppsRepository( return emptyList() } - database.setPushEnabled(accountId, testnet, appUrl, enabled) + database.setPushEnabled(accountId, network, appUrl, enabled) _connectionsFlow.value = otherConnections + accountConnections return accountConnections } @@ -237,19 +233,19 @@ class DAppsRepository( suspend fun deleteApp( accountId: String, - testnet: Boolean, + network: TonNetwork, appUrl: Uri, type: AppConnectEntity.Type? = null ): List { if (type == null) { val predicate: (AppConnectEntity) -> Boolean = { - it.accountId == accountId && it.testnet == testnet && it.appUrl.withoutQuery == appUrl.withoutQuery + it.accountId == accountId && it.network == network && it.appUrl.withoutQuery == appUrl.withoutQuery } return deleteConnections(predicate) } else { val predicate: (AppConnectEntity) -> Boolean = { - it.accountId == accountId && it.testnet == testnet && it.appUrl.withoutQuery == appUrl.withoutQuery && it.type == type + it.accountId == accountId && it.network == network && it.appUrl.withoutQuery == appUrl.withoutQuery && it.type == type } return deleteConnections(predicate) @@ -258,10 +254,10 @@ class DAppsRepository( suspend fun deleteApps( accountId: String, - testnet: Boolean + network: TonNetwork ): List { val predicate: (AppConnectEntity) -> Boolean = { - it.accountId == accountId && it.testnet == testnet + it.accountId == accountId && it.network == network } return deleteConnections(predicate) } @@ -317,17 +313,17 @@ class DAppsRepository( try { val tcApps = rnLegacy.getTCApps() for (app in tcApps.mainnet) { - migrationFromLegacy(app, false) + migrationFromLegacy(app, TonNetwork.MAINNET) } for (apps in tcApps.testnet) { - migrationFromLegacy(apps, true) + migrationFromLegacy(apps, TonNetwork.TESTNET) } } catch (e: Throwable) { recordException(e) } } - suspend fun migrationFromLegacy(connections: RNTCApps, testnet: Boolean) { + suspend fun migrationFromLegacy(connections: RNTCApps, network: TonNetwork) { val accountId = connections.address.toRawAddress() for (legacyApp in connections.apps) { val newApp = AppEntity( @@ -340,7 +336,7 @@ class DAppsRepository( for (legacyConnections in legacyApp.connections) { val newConnection = AppConnectEntity( accountId = accountId, - testnet = testnet, + network = network, clientId = legacyConnections.clientId, type = if (legacyConnections.type == "remote") AppConnectEntity.Type.External else AppConnectEntity.Type.Internal, appUrl = newApp.url, @@ -361,18 +357,16 @@ class DAppsRepository( val (mainnetConnections, testnetConnections) = LegacyHelper.sortByNetworkAndAccount(connections) - addToLegacyCreateApps(testnetConnections, true, appsMap) - val data = RNTC( - mainnet = addToLegacyCreateApps(mainnetConnections, false, appsMap), - testnet = addToLegacyCreateApps(testnetConnections, true, appsMap) + mainnet = addToLegacyCreateApps(mainnetConnections, TonNetwork.MAINNET, appsMap), + testnet = addToLegacyCreateApps(testnetConnections, TonNetwork.TESTNET, appsMap) ) rnLegacy.setTCApps(data) } private fun addToLegacyCreateApps( connectionsMap: ArrayMap>, - testnet: Boolean, + network: TonNetwork, appsMap: Map ): List { val legacyApps = mutableListOf() @@ -383,7 +377,11 @@ class DAppsRepository( for ((appUrl, appUrlConnections) in connectionsByAppUrls) { val app = appsMap[appUrl] ?: continue - val notificationsEnabled = isPushEnabled(accountId, testnet, appUrl) + val notificationsEnabled = isPushEnabled( + accountId = accountId, + network = network, + appUrl = appUrl + ) val legacyConnections = mutableListOf() for (appUrlConnection in appUrlConnections) { legacyConnections.add(LegacyHelper.createConnection(appUrlConnection)) @@ -402,7 +400,7 @@ class DAppsRepository( } legacyApps.add(RNTCApps( - address = accountId.toUserFriendly(wallet = true, bounceable = true, testnet = testnet), + address = accountId.toUserFriendly(wallet = true, bounceable = true, testnet = network.isTestnet), apps = legacyAccountApps )) } @@ -438,6 +436,12 @@ class DAppsRepository( companion object { + private val manifestPaths = arrayOf( + "tonconnect-manifest.json", + "manifest.json", + "tcm.json" + ) + fun fixAppTitle(value: String): String { var name = value.trim() if (name.contains(":")) { @@ -454,12 +458,6 @@ class DAppsRepository( } return name } - - private val manifestPaths = arrayOf( - "tonconnect-manifest.json", - "manifest.json", - "tcm.json" - ) } -} \ No newline at end of file +} diff --git a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/LegacyHelper.kt b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/LegacyHelper.kt index 69f93331c..44611a60e 100644 --- a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/LegacyHelper.kt +++ b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/LegacyHelper.kt @@ -2,6 +2,7 @@ package com.tonapps.wallet.data.dapps import android.net.Uri import androidx.collection.ArrayMap +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.data.dapps.entities.AppConnectEntity import com.tonapps.wallet.data.rn.data.RNTCConnection import com.tonapps.wallet.data.rn.data.RNTCKeyPair @@ -22,7 +23,8 @@ internal object LegacyHelper { val mainnetConnectionsMap = ArrayMap>() val testnetConnectionsMap = ArrayMap>() for (connection in connections) { - val list = (if (connection.testnet) { + val isTestnet = connection.network == TonNetwork.TESTNET + val list = (if (isTestnet) { testnetConnectionsMap[connection.accountId] } else { mainnetConnectionsMap[connection.accountId] @@ -30,7 +32,7 @@ internal object LegacyHelper { list.add(connection) - if (connection.testnet) { + if (isTestnet) { testnetConnectionsMap[connection.accountId] = list } else { mainnetConnectionsMap[connection.accountId] = list diff --git a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/entities/AppConnectEntity.kt b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/entities/AppConnectEntity.kt index b5a2090c8..f7eb19b5c 100644 --- a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/entities/AppConnectEntity.kt +++ b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/entities/AppConnectEntity.kt @@ -3,6 +3,7 @@ package com.tonapps.wallet.data.dapps.entities import android.net.Uri import android.os.Parcelable import android.util.Base64 +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.asJSON import com.tonapps.security.CryptoBox import com.tonapps.security.Sodium @@ -14,7 +15,7 @@ import org.json.JSONObject @Parcelize data class AppConnectEntity( val accountId: String, - val testnet: Boolean, + val network: TonNetwork, val clientId: String, val type: Type, val appUrl: Uri, diff --git a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/source/DatabaseSource.kt b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/source/DatabaseSource.kt index c82d6a6a2..8efd6b14c 100644 --- a/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/source/DatabaseSource.kt +++ b/apps/wallet/data/dapps/src/main/java/com/tonapps/wallet/data/dapps/source/DatabaseSource.kt @@ -2,21 +2,18 @@ package com.tonapps.wallet.data.dapps.source import android.content.ContentValues import android.content.Context -import android.content.SharedPreferences import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.net.Uri -import android.util.Log import androidx.core.content.edit import androidx.core.database.sqlite.transaction -import androidx.core.net.toUri import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.toRawAddress -import com.tonapps.extensions.getParcelable import com.tonapps.extensions.prefs +import com.tonapps.extensions.putByteArray import com.tonapps.extensions.putLong import com.tonapps.extensions.putParcelable -import com.tonapps.extensions.putString import com.tonapps.extensions.remove import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toParcel @@ -37,60 +34,9 @@ internal class DatabaseSource( context: Context ): SQLiteHelper(context, DATABASE_NAME, DATABASE_VERSION) { - private companion object { - private const val DATABASE_NAME = "dapps" - private const val DATABASE_VERSION = 2 - - private const val KEY_ALIAS = "_com_tonapps_dapps_master_key_" - - private const val LAST_EVENT_ID_KEY = "last_event_id" - private const val LAST_APP_REQUEST_ID_PREFIX = "last_app_request_id_" - - private const val APP_TABLE_NAME = "app" - private const val APP_TABLE_URL_COLUMN = "url" - private const val APP_TABLE_NAME_COLUMN = "name" - private const val APP_TABLE_ICON_URL_COLUMN = "icon_url" - - private const val CONNECT_TABLE_NAME = "connect" - private const val CONNECT_TABLE_APP_URL_COLUMN = "app_url" - private const val CONNECT_TABLE_ACCOUNT_ID_COLUMN = "account_id" - private const val CONNECT_TABLE_TESTNET_COLUMN = "testnet" - private const val CONNECT_TABLE_CLIENT_ID_COLUMN = "client_id" - private const val CONNECT_TABLE_TYPE_COLUMN = "type" - private const val CONNECT_TABLE_TIMESTAMP_COLUMN = "timestamp" - - private const val NOTIFICATIONS_TABLE_NAME = "notifications" - private const val NOTIFICATIONS_TABLE_ID_COLUMN = "id" - private const val NOTIFICATIONS_TABLE_APP_URL_COLUMN = "app_url" - private const val NOTIFICATIONS_TABLE_ACCOUNT_ID_COLUMN = "account_id" - private const val NOTIFICATIONS_TABLE_BODY_COLUMN = "body" - - private val appFields = arrayOf( - APP_TABLE_URL_COLUMN, - APP_TABLE_NAME_COLUMN, - APP_TABLE_ICON_URL_COLUMN - ).joinToString(",") - - private val connectFields = arrayOf( - CONNECT_TABLE_APP_URL_COLUMN, - CONNECT_TABLE_ACCOUNT_ID_COLUMN, - CONNECT_TABLE_TESTNET_COLUMN, - CONNECT_TABLE_CLIENT_ID_COLUMN, - CONNECT_TABLE_TYPE_COLUMN, - CONNECT_TABLE_TIMESTAMP_COLUMN - ).joinToString(",") - - private val notificationsFields = arrayOf( - NOTIFICATIONS_TABLE_ID_COLUMN, - NOTIFICATIONS_TABLE_APP_URL_COLUMN, - NOTIFICATIONS_TABLE_ACCOUNT_ID_COLUMN, - NOTIFICATIONS_TABLE_BODY_COLUMN - ).joinToString(",") - } - private val coroutineContext: CoroutineContext = Dispatchers.IO.limitedParallelism(1) - private val encryptedPrefs: SharedPreferences by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, DATABASE_NAME) } + private val encryptedPrefs by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, DATABASE_NAME) } private val prefs = context.prefs("tonconnect") private fun createAppTable(db: SQLiteDatabase) { @@ -108,7 +54,7 @@ internal class DatabaseSource( db.execSQL("CREATE TABLE $CONNECT_TABLE_NAME (" + "$CONNECT_TABLE_CLIENT_ID_COLUMN TEXT PRIMARY KEY," + "$CONNECT_TABLE_ACCOUNT_ID_COLUMN TEXT," + - "$CONNECT_TABLE_TESTNET_COLUMN INTEGER," + + "$CONNECT_TABLE_NETWORK_COLUMN INTEGER," + "$CONNECT_TABLE_TYPE_COLUMN INTEGER," + "$CONNECT_TABLE_APP_URL_COLUMN TEXT," + "$CONNECT_TABLE_TIMESTAMP_COLUMN INTEGER" + @@ -116,7 +62,7 @@ internal class DatabaseSource( val connectIndexPrefix = "idx_$CONNECT_TABLE_NAME" db.execSQL("CREATE UNIQUE INDEX ${connectIndexPrefix}_client_id ON $CONNECT_TABLE_NAME ($CONNECT_TABLE_CLIENT_ID_COLUMN)") - db.execSQL("CREATE INDEX ${connectIndexPrefix}_account_id_testnet ON $CONNECT_TABLE_NAME ($CONNECT_TABLE_ACCOUNT_ID_COLUMN, $CONNECT_TABLE_TESTNET_COLUMN)") + db.execSQL("CREATE INDEX ${connectIndexPrefix}_account_id_network ON $CONNECT_TABLE_NAME ($CONNECT_TABLE_ACCOUNT_ID_COLUMN, $CONNECT_TABLE_NETWORK_COLUMN)") db.execSQL("CREATE INDEX ${connectIndexPrefix}_app_url ON $CONNECT_TABLE_NAME ($CONNECT_TABLE_TYPE_COLUMN, $CONNECT_TABLE_APP_URL_COLUMN)") } @@ -144,6 +90,20 @@ internal class DatabaseSource( if (oldVersion < 2) { createNotificationsTable(db) } + if (oldVersion < 3) { + db.beginTransaction() + try { + db.execSQL("ALTER TABLE $CONNECT_TABLE_NAME ADD COLUMN $CONNECT_TABLE_NETWORK_COLUMN INTEGER DEFAULT ${TonNetwork.MAINNET.value}") + db.execSQL( + "UPDATE $CONNECT_TABLE_NAME SET $CONNECT_TABLE_NETWORK_COLUMN = " + + "CASE WHEN $CONNECT_TABLE_TESTNET_COLUMN = 1 THEN ${TonNetwork.TESTNET.value} " + + "ELSE ${TonNetwork.MAINNET.value} END" + ) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } } suspend fun insertNotifications(accountId: String, list: List) = withContext(coroutineContext) { @@ -220,10 +180,10 @@ internal class DatabaseSource( suspend fun insertConnection(connection: AppConnectEntity) = withContext(coroutineContext) { try { - val prefix = prefixAccount(connection.accountId, connection.testnet) + val prefix = prefixAccount(connection.accountId, connection.network) writableDatabase.delete(CONNECT_TABLE_NAME, "$CONNECT_TABLE_CLIENT_ID_COLUMN = ?", arrayOf(connection.clientId)) - encryptedPrefs.edit { + encryptedPrefs.transaction { remove(prefixKeyPair(prefix, connection.clientId)) remove(prefixProofSignature(prefix, connection.appUrl)) remove(prefixProofPayload(prefix, connection.appUrl)) @@ -232,19 +192,22 @@ internal class DatabaseSource( val values = ContentValues() values.put(CONNECT_TABLE_APP_URL_COLUMN, connection.appUrl.withoutQuery.toString().removeSuffix("/")) values.put(CONNECT_TABLE_ACCOUNT_ID_COLUMN, connection.accountId) - values.put(CONNECT_TABLE_TESTNET_COLUMN, if (connection.testnet) 1 else 0) + values.put(CONNECT_TABLE_NETWORK_COLUMN, connection.network.value) values.put(CONNECT_TABLE_CLIENT_ID_COLUMN, connection.clientId) values.put(CONNECT_TABLE_TYPE_COLUMN, connection.type.value) values.put(CONNECT_TABLE_TIMESTAMP_COLUMN, connection.timestamp) writableDatabase.insertOrThrow(CONNECT_TABLE_NAME, null, values) - encryptedPrefs.putParcelable(prefixKeyPair(prefix, connection.clientId), connection.keyPair) - if (connection.proofSignature != null) { - encryptedPrefs.putString(prefixProofSignature(prefix, connection.appUrl), connection.proofSignature) - } - if (connection.proofPayload != null) { - encryptedPrefs.putString(prefixProofPayload(prefix, connection.appUrl), connection.proofPayload) + encryptedPrefs.transaction { + putByteArray(prefixKeyPair(prefix, connection.clientId), connection.keyPair.toByteArray()) + + if (connection.proofSignature != null) { + putString(prefixProofSignature(prefix, connection.appUrl), connection.proofSignature) + } + if (connection.proofPayload != null) { + putString(prefixProofPayload(prefix, connection.appUrl), connection.proofPayload) + } } } catch (e: Throwable) { FirebaseCrashlytics.getInstance().recordException(e) @@ -253,11 +216,15 @@ internal class DatabaseSource( suspend fun deleteConnect(connection: AppConnectEntity): Boolean = withContext(coroutineContext) { val count = writableDatabase.delete(CONNECT_TABLE_NAME, "$CONNECT_TABLE_CLIENT_ID_COLUMN = ?", arrayOf(connection.clientId)) - encryptedPrefs.edit { - val prefix = prefixAccount(connection.accountId, connection.testnet) + encryptedPrefs.transaction { + val prefix = prefixAccount(connection.accountId, connection.network) + val legacyPrefix = legacyPrefixAccount(connection.accountId, connection.network) remove(prefixKeyPair(prefix, connection.clientId)) remove(prefixProofSignature(prefix, connection.appUrl)) remove(prefixProofPayload(prefix, connection.appUrl)) + remove(prefixKeyPair(legacyPrefix, connection.clientId)) + remove(prefixProofSignature(legacyPrefix, connection.appUrl)) + remove(prefixProofPayload(legacyPrefix, connection.appUrl)) } count > 0 } @@ -273,7 +240,7 @@ internal class DatabaseSource( private fun readConnections(cursor: Cursor): List { val appUrlIndex = cursor.getColumnIndex(CONNECT_TABLE_APP_URL_COLUMN) val accountIdIndex = cursor.getColumnIndex(CONNECT_TABLE_ACCOUNT_ID_COLUMN) - val testnetIndex = cursor.getColumnIndex(CONNECT_TABLE_TESTNET_COLUMN) + val networkIndex = cursor.getColumnIndex(CONNECT_TABLE_NETWORK_COLUMN) val clientIdIndex = cursor.getColumnIndex(CONNECT_TABLE_CLIENT_ID_COLUMN) val typeIndex = cursor.getColumnIndex(CONNECT_TABLE_TYPE_COLUMN) val timestampIndex = cursor.getColumnIndex(CONNECT_TABLE_TIMESTAMP_COLUMN) @@ -281,39 +248,75 @@ internal class DatabaseSource( val connections = mutableListOf() while (cursor.moveToNext()) { val accountId = cursor.getString(accountIdIndex) - val testnet = cursor.getInt(testnetIndex) == 1 + val networkValue = cursor.getInt(networkIndex) + val network = TonNetwork.entries.firstOrNull { it.value == networkValue } ?: continue val clientId = cursor.getString(clientIdIndex) val appUrl = Uri.parse(cursor.getString(appUrlIndex)).withoutQuery - val prefix = prefixAccount(accountId, testnet) - val connectionEncrypted = getConnectionEncrypted(prefix, clientId, appUrl) ?: continue + + val prefix = prefixAccount(accountId, network) + var connectionEncrypted = getConnectionEncrypted(prefix, clientId, appUrl) + if (connectionEncrypted == null) { + val legacyPrefix = legacyPrefixAccount(accountId, network) + connectionEncrypted = getConnectionEncrypted(legacyPrefix, clientId, appUrl) + if (connectionEncrypted != null) { + migrateEncryptedData(legacyPrefix, prefix, clientId, appUrl, connectionEncrypted) + } + } + connectionEncrypted ?: continue connections.add(AppConnectEntity( appUrl = appUrl, accountId = accountId, - testnet = testnet, - clientId = cursor.getString(clientIdIndex), + network = network, + clientId = clientId, type = AppConnectEntity.Type.entries.first { it.value == cursor.getInt(typeIndex) }, keyPair = connectionEncrypted.keyPair, proofSignature = connectionEncrypted.proofSignature, proofPayload = connectionEncrypted.proofPayload, timestamp = cursor.getLong(timestampIndex), - pushEnabled = isPushEnabled(accountId, testnet, appUrl) + pushEnabled = isPushEnabled(accountId, network, appUrl) )) } return connections } + // TODO TK-125 + private fun migrateEncryptedData( + oldPrefix: String, + newPrefix: String, + clientId: String, + appUrl: Uri, + encrypted: ConnectionEncryptedEntity + ) { + encryptedPrefs.transaction { + remove(prefixKeyPair(oldPrefix, clientId)) + remove(prefixProofSignature(oldPrefix, appUrl)) + remove(prefixProofPayload(oldPrefix, appUrl)) + putParcelable(prefixKeyPair(newPrefix, clientId), encrypted.keyPair) + + if (encrypted.proofSignature != null) { + putString(prefixProofSignature(newPrefix, appUrl), encrypted.proofSignature) + } + if (encrypted.proofPayload != null) { + putString(prefixProofPayload(newPrefix, appUrl), encrypted.proofPayload) + } + } + } + private fun getConnectionEncrypted( prefix: String, clientId: String, appUrl: Uri ): ConnectionEncryptedEntity? { try { - val keyPair = encryptedPrefs.getParcelable(prefixKeyPair(prefix, clientId)) ?: return null + val keyPair = encryptedPrefs.getByteArray(prefixKeyPair(prefix, clientId)) + ?.toParcel() + ?: return null + return ConnectionEncryptedEntity( keyPair = keyPair, - proofSignature = encryptedPrefs.getString(prefixProofSignature(prefix, appUrl), null), - proofPayload = encryptedPrefs.getString(prefixProofPayload(prefix, appUrl), null), + proofSignature = encryptedPrefs.get(prefixProofSignature(prefix, appUrl)), + proofPayload = encryptedPrefs.get(prefixProofPayload(prefix, appUrl)), ) } catch (e: Throwable) { FirebaseCrashlytics.getInstance().recordException(e) @@ -349,11 +352,12 @@ internal class DatabaseSource( return "push_${prefix}_${appUrl}" } - private fun prefixAccount( - accountId: String, - testnet: Boolean - ): String { - return "account_${accountId}:${if (testnet) "1" else "0"}" + private fun prefixAccount(accountId: String, network: TonNetwork): String { + return "account_${accountId}:${network.value}" + } + + private fun legacyPrefixAccount(accountId: String, network: TonNetwork): String { + return "account_${accountId}:${if (network.isTestnet) "1" else "0"}" } internal fun getLastEventId(): Long { @@ -376,13 +380,17 @@ internal class DatabaseSource( } } - internal fun isPushEnabled(accountId: String, testnet: Boolean, appUrl: Uri): Boolean { - return prefs.getBoolean(prefixPush(prefixAccount(accountId, testnet), appUrl), false) + internal fun isPushEnabled(accountId: String, network: TonNetwork, appUrl: Uri): Boolean { + val key = prefixPush(prefixAccount(accountId, network), appUrl) + if (prefs.contains(key)) { + return prefs.getBoolean(key, false) + } + return prefs.getBoolean(prefixPush(legacyPrefixAccount(accountId, network), appUrl), false) } - internal fun setPushEnabled(accountId: String, testnet: Boolean, appUrl: Uri, enabled: Boolean) { + internal fun setPushEnabled(accountId: String, network: TonNetwork, appUrl: Uri, enabled: Boolean) { prefs.edit { - putBoolean(prefixPush(prefixAccount(accountId, testnet), appUrl), enabled) + putBoolean(prefixPush(prefixAccount(accountId, network), appUrl), enabled) } } @@ -395,4 +403,56 @@ internal class DatabaseSource( writableDatabase.delete(CONNECT_TABLE_NAME, null, null) } -} \ No newline at end of file + private companion object { + private const val DATABASE_NAME = "dapps" + private const val DATABASE_VERSION = 3 + + private const val KEY_ALIAS = "_com_tonapps_dapps_master_key_" + + private const val LAST_EVENT_ID_KEY = "last_event_id" + private const val LAST_APP_REQUEST_ID_PREFIX = "last_app_request_id_" + + private const val APP_TABLE_NAME = "app" + private const val APP_TABLE_URL_COLUMN = "url" + private const val APP_TABLE_NAME_COLUMN = "name" + private const val APP_TABLE_ICON_URL_COLUMN = "icon_url" + + private const val CONNECT_TABLE_NAME = "connect" + private const val CONNECT_TABLE_APP_URL_COLUMN = "app_url" + private const val CONNECT_TABLE_ACCOUNT_ID_COLUMN = "account_id" + private const val CONNECT_TABLE_TESTNET_COLUMN = "testnet" + private const val CONNECT_TABLE_NETWORK_COLUMN = "network" + private const val CONNECT_TABLE_CLIENT_ID_COLUMN = "client_id" + private const val CONNECT_TABLE_TYPE_COLUMN = "type" + private const val CONNECT_TABLE_TIMESTAMP_COLUMN = "timestamp" + + private const val NOTIFICATIONS_TABLE_NAME = "notifications" + private const val NOTIFICATIONS_TABLE_ID_COLUMN = "id" + private const val NOTIFICATIONS_TABLE_APP_URL_COLUMN = "app_url" + private const val NOTIFICATIONS_TABLE_ACCOUNT_ID_COLUMN = "account_id" + private const val NOTIFICATIONS_TABLE_BODY_COLUMN = "body" + + private val appFields = arrayOf( + APP_TABLE_URL_COLUMN, + APP_TABLE_NAME_COLUMN, + APP_TABLE_ICON_URL_COLUMN + ).joinToString(",") + + private val connectFields = arrayOf( + CONNECT_TABLE_APP_URL_COLUMN, + CONNECT_TABLE_ACCOUNT_ID_COLUMN, + CONNECT_TABLE_NETWORK_COLUMN, + CONNECT_TABLE_CLIENT_ID_COLUMN, + CONNECT_TABLE_TYPE_COLUMN, + CONNECT_TABLE_TIMESTAMP_COLUMN + ).joinToString(",") + + private val notificationsFields = arrayOf( + NOTIFICATIONS_TABLE_ID_COLUMN, + NOTIFICATIONS_TABLE_APP_URL_COLUMN, + NOTIFICATIONS_TABLE_ACCOUNT_ID_COLUMN, + NOTIFICATIONS_TABLE_BODY_COLUMN + ).joinToString(",") + } + +} diff --git a/apps/wallet/data/events/build.gradle.kts b/apps/wallet/data/events/build.gradle.kts index 26fd17466..56efde99c 100644 --- a/apps/wallet/data/events/build.gradle.kts +++ b/apps/wallet/data/events/build.gradle.kts @@ -1,30 +1,26 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") id("com.google.devtools.ksp") kotlin("plugin.serialization") } -android { - namespace = Build.namespacePrefix("wallet.data.events") -} - dependencies { - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.rates)) - implementation(project(ProjectModules.Wallet.Data.collectibles)) - implementation(project(ProjectModules.Wallet.Data.staking)) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.icu)) - implementation(project(ProjectModules.Lib.security)) - implementation(project(ProjectModules.Lib.sqlite)) + implementation(projects.tonapi.legacy) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.rates) + implementation(projects.apps.wallet.data.collectibles) + implementation(projects.apps.wallet.data.staking) + implementation(projects.apps.wallet.api) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) + implementation(projects.lib.security) + implementation(projects.lib.sqlite) - implementation(libs.compose.paging) + implementation(libs.koin.core) - implementation(libs.androidX.room.runtime) - implementation(libs.androidX.room.ktx) - ksp(libs.androidX.room.compiler) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) } diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/EventsRepository.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/EventsRepository.kt index 221bb5fe9..d09fffb00 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/EventsRepository.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/EventsRepository.kt @@ -1,10 +1,10 @@ package com.tonapps.wallet.data.events import android.content.Context -import android.util.Log +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.api.tron.entity.TronEventEntity import com.tonapps.wallet.data.collectibles.CollectiblesRepository @@ -18,6 +18,7 @@ import com.tonapps.wallet.data.events.tx.db.TxDatabase import com.tonapps.wallet.data.rates.RatesRepository import io.tonapi.models.AccountEvent import io.tonapi.models.AccountEvents +import com.tonapps.wallet.data.events.tx.model.TxEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -59,10 +60,6 @@ class EventsRepository( localDataSource.saveDecryptedComment(txId, comment) } - fun clearTxEvents(account: BlockchainAddress) { - localDataSource.clearTxEvents(account) - } - suspend fun fetch(query: TxFetchQuery): TxPage { val events = remoteDataSource.events(query) return TxPage( @@ -74,6 +71,37 @@ class EventsRepository( ) } + suspend fun fetchLocal(accountId: String, query: TxFetchQuery): TxPage? = withContext(Dispatchers.IO) { + val key = cacheEventsKey(accountId, query.tonAddress.network) + val accountEvents = localDataSource.getEvents(key) ?: return@withContext null + val events = txActionMapper.events(query.tonAddress, accountEvents.events) + TxPage( + source = TxPage.Source.LOCAL, + events = events, + beforeTimestamp = query.beforeTimestamp, + afterTimestamp = query.afterTimestamp, + limit = query.limit + ) + } + + fun addLocalEvent(accountId: String, network: TonNetwork, event: AccountEvent) { + val key = cacheEventsKey(accountId, network) + val existing = localDataSource.getEvents(key) + val updatedEvents = if (existing != null) { + val filtered = existing.events.filter { it.eventId != event.eventId } + AccountEvents( + events = listOf(event) + filtered, + nextFrom = existing.nextFrom, + ) + } else { + AccountEvents( + events = listOf(event), + nextFrom = 0, + ) + } + localDataSource.setEvents(key, updatedEvents) + } + suspend fun tronLatestSentTransactions( tronWalletAddress: String, tonProofToken: String ): List { @@ -86,34 +114,49 @@ class EventsRepository( return sentTransactions.take(6) } - fun latestRecipientsFlow(accountId: String, testnet: Boolean) = flow { - localDataSource.getLatestRecipients(cacheLatestRecipientsKey(accountId, testnet))?.let { + fun latestRecipientsFlow(accountId: String, network: TonNetwork) = flow { + localDataSource.getLatestRecipients(cacheLatestRecipientsKey(accountId, network))?.let { emit(it) } - val remote = loadLatestRecipients(accountId, testnet) + val remote = loadLatestRecipients(accountId, network) emit(remote) }.flowOn(Dispatchers.IO) - private fun loadLatestRecipients(accountId: String, testnet: Boolean): List { - val list = remoteDataSource.getLatestRecipients(accountId, testnet) - localDataSource.setLatestRecipients(cacheLatestRecipientsKey(accountId, testnet), list) + private fun loadLatestRecipients(accountId: String, network: TonNetwork): List { + val list = remoteDataSource.getLatestRecipients(accountId, network) + localDataSource.setLatestRecipients(cacheLatestRecipientsKey(accountId, network), list) return list } - suspend fun getSingle(eventId: String, testnet: Boolean) = remoteDataSource.getSingle(eventId, testnet) + suspend fun getSingle(eventId: String, network: TonNetwork) = remoteDataSource.getSingle(eventId, network) + + suspend fun mapAccountEventsToTxEvents( + address: BlockchainAddress, + events: List, + ): List = withContext(Dispatchers.IO) { + txActionMapper.events(address, events) + } + + suspend fun mapTronEventsToTxEvents( + address: BlockchainAddress, + events: List, + ): List = withContext(Dispatchers.IO) { + txActionMapper.tronEvents(address, events) + } suspend fun loadForToken( tokenAddress: String, accountId: String, - testnet: Boolean, - beforeLt: Long? = null + network: TonNetwork, + beforeLt: Long? = null, + limit: Int = 10 ): AccountEvents? = withContext(Dispatchers.IO) { if (tokenAddress == TokenEntity.TON.address) { - getRemote(accountId, testnet, beforeLt) + getRemote(accountId, network, beforeLt, limit) } else { try { - api.getTokenEvents(tokenAddress, accountId, testnet, beforeLt) + api.getTokenEvents(tokenAddress, accountId, network, beforeLt, limit) } catch (e: Throwable) { null } @@ -141,26 +184,26 @@ class EventsRepository( suspend fun get( accountId: String, - testnet: Boolean - ) = getLocal(accountId, testnet) ?: getRemote(accountId, testnet) + network: TonNetwork + ) = getLocal(accountId, network) ?: getRemote(accountId, network) suspend fun getRemote( accountId: String, - testnet: Boolean, + network: TonNetwork, beforeLt: Long? = null, limit: Int = 10 ): AccountEvents? = withContext(Dispatchers.IO) { try { val accountEvents = if (beforeLt != null) { - remoteDataSource.get(accountId, testnet, beforeLt, limit) + remoteDataSource.get(accountId, network, beforeLt, limit) } else { - val events = remoteDataSource.get(accountId, testnet, null, limit)?.also { - localDataSource.setEvents(cacheEventsKey(accountId, testnet), it) + val events = remoteDataSource.get(accountId, network, null, limit)?.also { + localDataSource.setEvents(cacheEventsKey(accountId, network), it) } events } ?: return@withContext null - localDataSource.addSpam(accountId, testnet, accountEvents.events.filter { + localDataSource.addSpam(accountId, network.isTestnet, accountEvents.events.filter { it.isScam }) @@ -170,17 +213,17 @@ class EventsRepository( } } - suspend fun getLocalSpam(accountId: String, testnet: Boolean) = withContext(Dispatchers.IO) { - localDataSource.getSpam(accountId, testnet) + suspend fun getLocalSpam(accountId: String, network: TonNetwork) = withContext(Dispatchers.IO) { + localDataSource.getSpam(accountId, network.isTestnet) } suspend fun markAsSpam( accountId: String, - testnet: Boolean, + network: TonNetwork, eventId: String, ) = withContext(Dispatchers.IO) { - val events = getSingle(eventId, testnet) ?: return@withContext - localDataSource.addSpam(accountId, testnet, events) + val events = getSingle(eventId, network) ?: return@withContext + localDataSource.addSpam(accountId, network.isTestnet, events) _hiddenTxIdsFlow.update { it.plus(eventId) } @@ -188,10 +231,10 @@ class EventsRepository( suspend fun removeSpam( accountId: String, - testnet: Boolean, + network: TonNetwork, eventId: String, ) = withContext(Dispatchers.IO) { - localDataSource.removeSpam(accountId, testnet, eventId) + localDataSource.removeSpam(accountId, network.isTestnet, eventId) _hiddenTxIdsFlow.update { it.minus(eventId) } @@ -199,7 +242,7 @@ class EventsRepository( suspend fun getRemoteSpam( accountId: String, - testnet: Boolean, + network: TonNetwork, startBeforeLt: Long? = null ) = withContext(Dispatchers.IO) { val list = mutableListOf() @@ -207,7 +250,7 @@ class EventsRepository( for (i in 0 until 10) { val events = remoteDataSource.get( accountId = accountId, - testnet = testnet, + network = network, beforeLt = beforeLt, limit = 50 )?.events ?: emptyList() @@ -220,29 +263,23 @@ class EventsRepository( beforeLt = events.lastOrNull()?.lt ?: break } val spamList = list.filter { it.isScam } - localDataSource.addSpam(accountId, testnet, spamList) + localDataSource.addSpam(accountId, network.isTestnet, spamList) spamList } suspend fun getLocal( accountId: String, - testnet: Boolean + network: TonNetwork ): AccountEvents? = withContext(Dispatchers.IO) { - localDataSource.getEvents(cacheEventsKey(accountId, testnet)) + localDataSource.getEvents(cacheEventsKey(accountId, network)) } - private fun cacheEventsKey(accountId: String, testnet: Boolean): String { - if (!testnet) { - return accountId - } - return "${accountId}_testnet" + private fun cacheEventsKey(accountId: String, network: TonNetwork): String { + return "${accountId}_${network.name.lowercase()}" } - private fun cacheLatestRecipientsKey(accountId: String, testnet: Boolean): String { - if (!testnet) { - return accountId - } - return "${accountId}_testnet" + private fun cacheLatestRecipientsKey(accountId: String, network: TonNetwork): String { + return "${accountId}_${network.name.lowercase()}" } -} \ No newline at end of file +} diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/Extensions.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/Extensions.kt index 643412384..7c46fc7cc 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/Extensions.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/Extensions.kt @@ -1,8 +1,9 @@ package com.tonapps.wallet.data.events +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.icu.Coins -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.rates.RatesRepository import io.tonapi.models.AccountAddress import io.tonapi.models.AccountEvent @@ -12,12 +13,12 @@ import io.tonapi.models.JettonTransferAction val JettonTransferAction.amountCoins: Coins get() = Coins.ofNano(amount, jetton.decimals) -suspend fun Action.getTonAmountRaw(ratesRepository: RatesRepository): Coins { +suspend fun Action.getTonAmountRaw(network: TonNetwork, ratesRepository: RatesRepository): Coins { val tonAmount = tonTransfer?.let { Coins.of(it.amount) } val jettonAmountInTON = jettonTransfer?.let { val amountCoins = it.amountCoins val jettonAddress = it.jetton.address - val rates = ratesRepository.getRates(WalletCurrency.TON, jettonAddress) + val rates = ratesRepository.getRates(TonNetwork.MAINNET, WalletCurrency.TON, jettonAddress) rates.convert(jettonAddress, amountCoins) } return tonAmount ?: jettonAmountInTON ?: Coins.ZERO diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/DatabaseSource.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/DatabaseSource.kt index f517beb70..10d0d31d3 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/DatabaseSource.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/DatabaseSource.kt @@ -7,7 +7,7 @@ import com.tonapps.blockchain.ton.extensions.toRawAddress import com.tonapps.extensions.toByteArray import com.tonapps.sqlite.SQLiteHelper import com.tonapps.sqlite.withTransaction -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.data.events.tx.model.TxEvent import io.Serializer import io.tonapi.models.AccountEvent diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/LocalDataSource.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/LocalDataSource.kt index 6a3c43244..f440e876e 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/LocalDataSource.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/LocalDataSource.kt @@ -5,7 +5,7 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.tonapps.extensions.MutableEffectFlow import com.tonapps.security.Security -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.api.tron.entity.TronEventEntity import com.tonapps.wallet.data.core.BlobDataSource import com.tonapps.wallet.data.events.entities.LatestRecipientEntity @@ -30,7 +30,6 @@ internal class LocalDataSource( private const val KEY_ALIAS = "_com_tonapps_events_master_key_" } - private val txEvents = BlobDataSource.simple(context, "tx_events") private val databaseSource: DatabaseSource = DatabaseSource(scope, context) private val eventsCache = BlobDataSource.simpleJSON(context, "events") private val tronEventsCache = BlobDataSource.simpleJSON>(context, "tron_events") @@ -39,24 +38,7 @@ internal class LocalDataSource( private val _decryptedCommentFlow = MutableStateFlow(emptyMap()) val decryptedCommentFlow = _decryptedCommentFlow.stateIn(scope, SharingStarted.WhileSubscribed(), emptyMap()) - private val encryptedPrefs: SharedPreferences by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, NAME) } - - fun getTxEvents(account: BlockchainAddress): List { - val events = txEvents.getCache(account.key)?.events - return events ?: listOf() - } - - fun clearTxEvents(account: BlockchainAddress) { - txEvents.clearCache(account.key) - } - - fun setTxEvents(account: BlockchainAddress, events: List) { - if (events.isEmpty()) { - clearTxEvents(account) - } else { - txEvents.setCache(account.key, TxEvents(events)) - } - } + private val encryptedPrefs by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, NAME) } private fun keyDecryptedComment(txId: String): String { return "tx_$txId" @@ -75,12 +57,15 @@ internal class LocalDataSource( } fun getDecryptedComment(txId: String): String? { - return encryptedPrefs.getString(keyDecryptedComment(txId), null) + return encryptedPrefs.get(keyDecryptedComment(txId)) } fun saveDecryptedComment(txId: String, comment: String) { - encryptedPrefs.edit { + val isSuccess = encryptedPrefs.transaction { putString(keyDecryptedComment(txId), comment) + } + + if (isSuccess) { _decryptedCommentFlow.value += (txId to comment) } } @@ -89,10 +74,6 @@ internal class LocalDataSource( return eventsCache.getCache(key) } - fun getTronEvents(key: String): List? { - return tronEventsCache.getCache(key) - } - fun setTronEvents(key: String, events: List) { tronEventsCache.setCache(key, events) } diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/RemoteDataSource.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/RemoteDataSource.kt index 050ba4db9..a2d04bddd 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/RemoteDataSource.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/source/RemoteDataSource.kt @@ -1,8 +1,9 @@ package com.tonapps.wallet.data.events.source +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.data.events.tx.TxActionMapper import com.tonapps.wallet.data.events.entities.LatestRecipientEntity @@ -22,7 +23,13 @@ internal class RemoteDataSource( suspend fun events(query: TxFetchQuery): List = coroutineScope { val fetchLimit = query.limit val tonDeferred = async { - tonEvents(query.tonAddress, query.beforeTimestamp, query.afterTimestamp, fetchLimit) + tonEvents( + address = query.tonAddress, + network = query.tonAddress.network, + beforeTimestamp = query.beforeTimestamp, + afterTimestamp = query.afterTimestamp, + limit = fetchLimit + ) } val tronDeferred = async { @@ -38,13 +45,14 @@ internal class RemoteDataSource( suspend fun tonEvents( address: BlockchainAddress, + network: TonNetwork, beforeTimestamp: Timestamp?, afterTimestamp: Timestamp?, limit: Int, ): List { val events = api.fetchTonEvents( accountId = address.value, - testnet = address.testnet, + network = network, beforeTimestamp = beforeTimestamp, afterTimestamp = afterTimestamp, limit = limit @@ -52,7 +60,7 @@ internal class RemoteDataSource( return mapper.events(address, events) } - fun tronEvents( + suspend fun tronEvents( address: BlockchainAddress, tonProofToken: String, beforeTimestamp: Timestamp?, @@ -71,17 +79,17 @@ internal class RemoteDataSource( fun get( accountId: String, - testnet: Boolean, + network: TonNetwork, beforeLt: Long? = null, limit: Int = 12 - ): AccountEvents? = api.getEvents(accountId, testnet, beforeLt, limit) + ): AccountEvents? = api.getEvents(accountId, network, beforeLt, limit) - suspend fun getSingle(eventId: String, testnet: Boolean) = api.getSingleEvent(eventId, testnet) + suspend fun getSingle(eventId: String, network: TonNetwork) = api.getSingleEvent(eventId, network) - fun getLatestRecipients(accountId: String, testnet: Boolean): List { + fun getLatestRecipients(accountId: String, network: TonNetwork): List { val events = api.getEvents( accountId = accountId, - testnet = testnet, + network = network, limit = 100 )?.events ?: return emptyList() diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxActionMapper.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxActionMapper.kt index dab86f3e8..fe08ddeca 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxActionMapper.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxActionMapper.kt @@ -1,23 +1,24 @@ package com.tonapps.wallet.data.events.tx -import android.util.Log +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.icu.Coins import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.api.tron.entity.TronEventEntity import com.tonapps.wallet.data.collectibles.CollectiblesRepository import com.tonapps.wallet.data.collectibles.entities.NftEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.events.ActionType +import com.tonapps.wallet.data.events.getTonAmountRaw +import com.tonapps.wallet.data.events.isOutTransfer import com.tonapps.wallet.data.events.tx.model.TxAction import com.tonapps.wallet.data.events.tx.model.TxActionBody import com.tonapps.wallet.data.events.tx.model.TxEvent import com.tonapps.wallet.data.events.tx.model.TxFlag -import com.tonapps.wallet.data.events.getTonAmountRaw -import com.tonapps.wallet.data.events.isOutTransfer import com.tonapps.wallet.data.rates.RatesRepository import com.tonapps.wallet.data.staking.StakingPool import io.tonapi.models.AccountAddress @@ -54,8 +55,29 @@ import io.tonapi.models.UnSubscriptionAction import io.tonapi.models.WithdrawStakeAction import io.tonapi.models.WithdrawStakeRequestAction import io.tonapi.models.WithdrawTokenStakeRequestAction +import android.net.Uri +import com.tonapps.blockchain.ton.extensions.toRawAddress import kotlin.math.abs +private fun JettonPreview.toTokenEntity() = TokenEntity( + blockchain = Blockchain.TON, + address = address.toRawAddress(), + name = name, + symbol = symbol, + imageUri = Uri.parse(image), + decimals = decimals, + verification = when (verification) { + JettonVerificationType.whitelist -> TokenEntity.Verification.whitelist + JettonVerificationType.blacklist -> TokenEntity.Verification.blacklist + else -> TokenEntity.Verification.none + }, + isRequestMinting = false, + isTransferable = true, + customPayloadApiUri = customPayloadApiUri, + numerator = scaledUi?.numerator?.toBigDecimal(), + denominator = scaledUi?.denominator?.toBigDecimal() +) + internal class TxActionMapper( private val collectiblesRepository: CollectiblesRepository, private val ratesRepository: RatesRepository, @@ -72,13 +94,14 @@ internal class TxActionMapper( val currency = WalletCurrency.USDT_TRON val isOutgoing = event.from == address.value val builder = TxActionBody.Builder(if (isOutgoing) ActionType.Send else ActionType.Received) + val isTestnet = address.network.isTestnet builder.setRecipient(TxActionBody.Account( address = event.to, - testnet = address.testnet + testnet = isTestnet )) builder.setSender(TxActionBody.Account( address = event.from, - testnet = address.testnet + testnet = isTestnet )) val isScam = event.from != address.value && event.amount < Coins.of(0.1, currency.decimals) if (isOutgoing) { @@ -153,7 +176,7 @@ internal class TxActionMapper( private fun fetchNft(address: BlockchainAddress, nftAddress: String) = collectiblesRepository.getNft( accountId = address.value, - testnet = address.testnet, + network = address.network, address = nftAddress ) @@ -165,7 +188,7 @@ internal class TxActionMapper( imageUrl = product.thumbUri.toString() ) - private fun product(nft: NftItem, testnet: Boolean) = product(NftEntity(nft, testnet)) + private fun product(nft: NftItem, network: TonNetwork) = product(NftEntity(nft, network)) private fun text(comment: String?, encryptedComment: EncryptedComment?): TxActionBody.Text? { if (comment != null) { @@ -217,8 +240,8 @@ internal class TxActionMapper( private suspend fun isMaybeSpam(address: BlockchainAddress, action: Action): Boolean { val isTransfer = action.type == Action.Type.TonTransfer || action.type == Action.Type.JettonTransfer if (isTransfer && !action.isOutTransfer(address.value)) { - val total = action.getTonAmountRaw(ratesRepository) - return total < api.config.reportAmount + val total = action.getTonAmountRaw(address.network, ratesRepository) + return total < api.getConfig(address.network).reportAmount } else { return false } @@ -314,28 +337,28 @@ internal class TxActionMapper( private fun removeExtension(address: BlockchainAddress, action: RemoveExtensionAction): TxActionBody { val builder = TxActionBody.Builder(ActionType.RemoveExtension) builder.setSubtitle(action.extension) - builder.setSender(account(action.wallet, address.testnet)) + builder.setSender(account(action.wallet, address.network.isTestnet)) return builder.build() } private fun addExtension(address: BlockchainAddress, action: AddExtensionAction): TxActionBody { val builder = TxActionBody.Builder(ActionType.AddExtension) builder.setSubtitle(action.extension) - builder.setSender(account(action.wallet, address.testnet)) + builder.setSender(account(action.wallet, address.network.isTestnet)) return builder.build() } private fun setSignatureAllowed(address: BlockchainAddress, action: SetSignatureAllowedAction): TxActionBody { val type = if (action.allowed) ActionType.SetSignatureAllowed else ActionType.SetSignatureNotAllowed val builder = TxActionBody.Builder(type) - builder.setSender(account(action.wallet, address.testnet)) + builder.setSender(account(action.wallet, address.network.isTestnet)) return builder.build() } private fun subscribe(address: BlockchainAddress, action: SubscriptionAction): TxActionBody { val amount = Coins.ofNano(action.price.value, action.price.decimals) val builder = TxActionBody.Builder(ActionType.Subscribe) - builder.setRecipient(account(action.beneficiary, address.testnet)) + builder.setRecipient(account(action.beneficiary, address.network.isTestnet)) builder.setSubtitle(action.subscription) builder.setOutgoingAmount(amount) builder.setImageUrl(action.beneficiary.icon) @@ -344,7 +367,7 @@ internal class TxActionMapper( private fun unSubscribe(address: BlockchainAddress, action: UnSubscriptionAction): TxActionBody { val builder = TxActionBody.Builder(ActionType.UnSubscribe) - builder.setRecipient(account(action.beneficiary, address.testnet)) + builder.setRecipient(account(action.beneficiary, address.network.isTestnet)) builder.setSubtitle(action.subscription) builder.setImageUrl(action.beneficiary.icon) return builder.build() @@ -364,8 +387,8 @@ internal class TxActionMapper( private fun nftPurchase(address: BlockchainAddress, action: NftPurchaseAction): TxActionBody { val currency = currency(action.amount) val amount = Coins.ofNano(action.amount.value, currency.decimals) - val recipient = account(action.seller, address.testnet) - val product = product(action.nft, address.testnet) + val recipient = account(action.seller, address.network.isTestnet) + val product = product(action.nft, address.network) val builder = TxActionBody.Builder(ActionType.NftPurchase) builder.setRecipient(recipient) @@ -381,7 +404,7 @@ internal class TxActionMapper( private fun withdrawStake(address: BlockchainAddress, action: WithdrawStakeAction): TxActionBody { val amount = Coins.of(action.amount) - val recipient = account(action.pool, address.testnet) + val recipient = account(action.pool, address.network.isTestnet) val builder = TxActionBody.Builder(ActionType.WithdrawStake) builder.setRecipient(recipient) builder.setOutgoingAmount(amount) @@ -392,9 +415,9 @@ internal class TxActionMapper( val currency = currency(action.amount) val amount = Coins.ofNano(action.amount.value, currency.decimals) val product = action.nft?.let { - product(it, address.testnet) + product(it, address.network) } - val recipient = account(action.auction, address.testnet) + val recipient = account(action.auction, address.network.isTestnet) val builder = TxActionBody.Builder(ActionType.AuctionBid) builder.setRecipient(recipient) builder.setOutgoingAmount(amount, currency) @@ -413,22 +436,28 @@ internal class TxActionMapper( return builder.build() } + private fun Price.toAmount(): TxActionBody.Value { + val currency = currency(this) + val amount = Coins.ofNano(value, decimals) + return TxActionBody.Value(amount, currency) + } + private fun withdrawStakeRequest(address: BlockchainAddress, action: WithdrawStakeRequestAction): TxActionBody { - val amount = Coins.of(action.amount ?: 0L) - val recipient = account(action.pool, address.testnet) + val recipient = account(action.pool, address.network.isTestnet) val builder = TxActionBody.Builder(ActionType.WithdrawStakeRequest) + val ingoingAmount = action.stakeMeta?.toAmount() builder.setRecipient(recipient) - builder.setOutgoingAmount(amount) + ingoingAmount?.let(builder::setOutgoingAmount) return builder.build() } private fun jettonMint(address: BlockchainAddress, action: JettonMintAction): TxActionBody { val amount = Coins.ofNano(action.amount, action.jetton.decimals) val currency = currency(action.jetton) - val recipient = account(action.recipient, address.testnet) + val recipient = account(action.recipient, address.network.isTestnet) val builder = TxActionBody.Builder(ActionType.JettonMint) builder.setRecipient(recipient) - builder.setOutgoingAmount(amount, currency) + builder.setIncomingAmount(amount, currency) if (action.jetton.verification != JettonVerificationType.whitelist) { builder.addFlag(TxFlag.UnverifiedToken) } @@ -469,10 +498,11 @@ internal class TxActionMapper( val implementation = StakingPool.implementation(action.implementation) // TODO fix after full move KMM val iconUrl = "android.resource://com.ton_keeper/${StakingPool.getIcon(implementation)}" - val amount = Coins.of(action.amount) val builder = TxActionBody.Builder(ActionType.DepositStake) - builder.setRecipient(account(action.pool, address.testnet)) - builder.setOutgoingAmount(amount) + action.stakeMeta?.toAmount() + ?.let { builder.setOutgoingAmount(it) } + ?: builder.setOutgoingAmount(Coins.of(action.amount)) + builder.setRecipient(account(action.pool, address.network.isTestnet)) builder.setImageUrl(iconUrl) return builder.build() } @@ -482,8 +512,8 @@ internal class TxActionMapper( private fun nftItemTransfer(address: BlockchainAddress, action: NftItemTransferAction): TxActionBody { val nft = fetchNft(address, action.nft) val product = nft?.let(::product) - val sender = action.sender?.let { account(it, address.testnet) } - val recipient = action.recipient?.let { account(it, address.testnet) } + val sender = action.sender?.let { account(it, address.network.isTestnet) } + val recipient = action.recipient?.let { account(it, address.network.isTestnet) } val isOutgoing = sender?.address?.equalsAddress(address.value) == true val builder = TxActionBody.Builder(if (isOutgoing) ActionType.NftSend else ActionType.NftReceived) sender?.let(builder::setSender) @@ -504,7 +534,7 @@ internal class TxActionMapper( private fun smartContract(address: BlockchainAddress, action: SmartContractAction): TxActionBody { val amount = Coins.of(action.tonAttached) val builder = TxActionBody.Builder(ActionType.CallContract) - builder.setSender(account(action.executor, address.testnet)) + builder.setSender(account(action.executor, address.network.isTestnet)) builder.setSubtitle(action.payload ?: action.operation) builder.setOutgoingAmount(amount) return builder.build() @@ -513,8 +543,8 @@ internal class TxActionMapper( private fun tonTransfer(address: BlockchainAddress, action: TonTransferAction): TxActionBody { val amount = Coins.of(action.amount) val currency = WalletCurrency.TON - val sender = account(action.sender, address.testnet) - val recipient = account(action.recipient, address.testnet) + val sender = account(action.sender, address.network.isTestnet) + val recipient = account(action.recipient, address.network.isTestnet) val isOutgoing = sender.address.equalsAddress(address.value) val builder = TxActionBody.Builder(if (isOutgoing) ActionType.Send else ActionType.Received) builder.setSender(sender) @@ -530,10 +560,11 @@ internal class TxActionMapper( } private fun jettonTransfer(address: BlockchainAddress, action: JettonTransferAction): TxActionBody { - val amount = Coins.ofNano(action.amount, action.jetton.decimals) + val jetton = action.jetton.toTokenEntity() + val amount = jetton.toUIAmount(Coins.ofNano(action.amount, jetton.decimals)) val currency = currency(action.jetton) - val sender = action.sender?.let { account(it, address.testnet) } - val recipient = action.recipient?.let { account(it, address.testnet) } + val sender = action.sender?.let { account(it, address.network.isTestnet) } + val recipient = action.recipient?.let { account(it, address.network.isTestnet) } val isOutgoing = recipient?.let { !it.address.equalsAddress(address.value) } ?: false @@ -558,23 +589,25 @@ internal class TxActionMapper( val incomingAmount = if (action.tonIn != null) { TxActionBody.Value(Coins.of(action.tonIn!!), WalletCurrency.TON) } else { + val jetton = action.jettonMasterIn!!.toTokenEntity() val currency = currency(action.jettonMasterIn!!) - TxActionBody.Value(Coins.ofNano(action.amountIn, currency.decimals), currency) + TxActionBody.Value(jetton.toUIAmount(Coins.ofNano(action.amountIn, jetton.decimals)), currency) } val outgoingAmount = if (action.tonOut != null) { TxActionBody.Value(Coins.of(action.tonOut!!), WalletCurrency.TON) } else { + val jetton = action.jettonMasterOut!!.toTokenEntity() val currency = currency(action.jettonMasterOut!!) - TxActionBody.Value(Coins.ofNano(action.amountOut, currency.decimals), currency) + TxActionBody.Value(jetton.toUIAmount(Coins.ofNano(action.amountOut, jetton.decimals)), currency) } val builder = TxActionBody.Builder(ActionType.Swap) builder.setSubtitle(action.dex) builder.setIncomingAmount(outgoingAmount) builder.setOutgoingAmount(incomingAmount) - builder.setRecipient(account(action.userWallet, address.testnet)) - builder.setSender(account(action.router, address.testnet)) + builder.setRecipient(account(action.userWallet, address.network.isTestnet)) + builder.setSender(account(action.router, address.network.isTestnet)) action.jettonMasterOut?.verification?.let { verification -> if (verification != JettonVerificationType.whitelist) { builder.addFlag(TxFlag.UnverifiedToken) @@ -592,7 +625,7 @@ internal class TxActionMapper( val amount = action.amount val coins = Coins.ofNano(amount.value, amount.decimals) val builder = TxActionBody.Builder(ActionType.Purchase) - builder.setRecipient(account(action.destination, address.testnet)) + builder.setRecipient(account(action.destination, address.network.isTestnet)) builder.setOutgoingAmount(coins, currency(amount)) if (amount.verification != TrustType.whitelist) { builder.addFlag(TxFlag.UnverifiedToken) @@ -603,7 +636,7 @@ internal class TxActionMapper( private fun gasRelay(address: BlockchainAddress, action: GasRelayAction): TxActionBody { val coins = Coins.of(action.amount) val builder = TxActionBody.Builder(ActionType.GasRelay) - builder.setRecipient(account(action.target, address.testnet)) + builder.setRecipient(account(action.target, address.network.isTestnet)) builder.setIncomingAmount(coins, WalletCurrency.TON) return builder.build() } @@ -615,11 +648,11 @@ internal class TxActionMapper( val sender = simplePreview.accounts.firstOrNull { it.address.equalsAddress(address.value) - }?.let { account(it, address.testnet) } + }?.let { account(it, address.network.isTestnet) } val recipient = simplePreview.accounts.firstOrNull { !it.address.equalsAddress(address.value) - }?.let { account(it, address.testnet) } + }?.let { account(it, address.network.isTestnet) } val builder = TxActionBody.Builder(ActionType.Unknown) builder.setTitle(simplePreview.name) @@ -631,4 +664,4 @@ internal class TxActionMapper( builder.setOutgoingAmount(Coins.ZERO, unknown) return builder.build() } -} \ No newline at end of file +} diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxFetchQuery.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxFetchQuery.kt index 592656cc0..7d0f12ef7 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxFetchQuery.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/TxFetchQuery.kt @@ -1,6 +1,6 @@ package com.tonapps.wallet.data.events.tx -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.api.entity.value.Timestamp data class TxFetchQuery( diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordDao.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordDao.kt index bf4fbad6d..d7ae7b833 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordDao.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordDao.kt @@ -5,8 +5,8 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.data.events.tx.model.TxEvent diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordEntity.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordEntity.kt index 656df0cd7..b1cc6c9e5 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordEntity.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/db/TxRecordEntity.kt @@ -4,8 +4,8 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.BlockchainAddress import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.data.events.tx.model.TxEvent diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxAction.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxAction.kt index b4cbb4d66..b68e95ce0 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxAction.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxAction.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.events.tx.model import android.os.Parcelable -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.events.ActionType import kotlinx.parcelize.Parcelize diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxActionBody.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxActionBody.kt index 90bb04ff0..5b6d4fea0 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxActionBody.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxActionBody.kt @@ -5,7 +5,7 @@ import com.tonapps.blockchain.ton.extensions.toUserFriendly import com.tonapps.extensions.short4 import com.tonapps.icu.Coins import com.tonapps.icu.CurrencyFormatter -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.events.ActionType import com.tonapps.wallet.data.events.ActionTypeOut import kotlinx.parcelize.IgnoredOnParcel diff --git a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxEvent.kt b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxEvent.kt index 845a06288..f850d6613 100644 --- a/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxEvent.kt +++ b/apps/wallet/data/events/src/main/java/com/tonapps/wallet/data/events/tx/model/TxEvent.kt @@ -1,8 +1,8 @@ package com.tonapps.wallet.data.events.tx.model import android.os.Parcelable +import com.tonapps.blockchain.contract.Blockchain import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.value.Blockchain import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.data.events.ActionType import kotlinx.parcelize.IgnoredOnParcel diff --git a/apps/wallet/data/features/build.gradle.kts b/apps/wallet/data/features/build.gradle.kts new file mode 100644 index 000000000..cc82f53b1 --- /dev/null +++ b/apps/wallet/data/features/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("target.android.library") +} + +dependencies { + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.config) + + implementation(projects.lib.log) + implementation(projects.lib.features) +} diff --git a/apps/wallet/data/features/src/main/kotlin/com/tonapps/core/flags/RemoteConfig.kt b/apps/wallet/data/features/src/main/kotlin/com/tonapps/core/flags/RemoteConfig.kt new file mode 100644 index 000000000..de6558183 --- /dev/null +++ b/apps/wallet/data/features/src/main/kotlin/com/tonapps/core/flags/RemoteConfig.kt @@ -0,0 +1,63 @@ +package com.tonapps.core.flags + +import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings +import com.tonapps.log.L + +@Deprecated("Use this class through Features") +class RemoteConfig : RemoteConfigProvider { + + private val remoteConfig: FirebaseRemoteConfig by lazy { + FirebaseRemoteConfig.getInstance() + } + + private enum class FeatureFlag(val key: String) { + IN_APP_UPDATE_AVAILABLE("inAppUpdateAvailable"), + ONBOARDING_STORIES_ENABLED("onboarding_stories_enabled"); + } + + // + init { + val configSettings = FirebaseRemoteConfigSettings.Builder() + .setMinimumFetchIntervalInSeconds(0) + .build() + + remoteConfig.setConfigSettingsAsync(configSettings) + + val defaults = mapOf( + FeatureFlag.ONBOARDING_STORIES_ENABLED.key to true, + ) + + remoteConfig.setDefaultsAsync(defaults) + } + + val inAppUpdateAvailable: Boolean + get() = remoteConfig.getBoolean(FeatureFlag.IN_APP_UPDATE_AVAILABLE.key) + + val isOnboardingStoriesEnabled: Boolean + get() = remoteConfig.getBoolean(FeatureFlag.ONBOARDING_STORIES_ENABLED.key) + + override fun isFeatureEnabled(feature: FeatureKey): Boolean { + return remoteConfig.getBoolean(feature.featureKey) + } + + override fun getFeatureValue(feature: FeatureKey, default: String): String { + val value = remoteConfig.getString(feature.featureKey) + return value.ifEmpty { default } + } + + override fun optFeatureValue(feature: FeatureKey): String? { + val value = remoteConfig.getString(feature.featureKey) + return value.ifEmpty { null } + } + + fun fetchAndActivate() { + remoteConfig.fetchAndActivate().addOnCompleteListener { task -> + if (task.isSuccessful) { + L.d("RemoteConfig", "Fetched and activated successfully") + } else { + L.e("RemoteConfig", "Fetch failed, using defaults") + } + } + } +} diff --git a/apps/wallet/data/features/src/main/kotlin/com/tonapps/core/flags/WalletFeature.kt b/apps/wallet/data/features/src/main/kotlin/com/tonapps/core/flags/WalletFeature.kt new file mode 100644 index 000000000..5b06aee5e --- /dev/null +++ b/apps/wallet/data/features/src/main/kotlin/com/tonapps/core/flags/WalletFeature.kt @@ -0,0 +1,51 @@ +package com.tonapps.core.flags + +import org.json.JSONObject +import com.tonapps.log.L + +enum class WalletFeatureKey( + override val featureKey: String, +) : FeatureKey { + IS_TRADING_TAB_ENABLED("android_is_trading_tab_enabled"), + IS_STREAMING_V2_ENABLED("android_is_streaming_api_v2_enabled"), + IS_WALLETKIT_ENABLED("walletkitEnabled"), + ; + + companion object { + fun asJsonString(): String? { + val flags = JSONObject() + entries.forEach { key -> + if (FeatureManager.isEnabled(key)) { + flags.put(key.featureKey, true) + } + } + return flags.takeIf { it.length() > 0 }?.toString() + } + } +} + +sealed interface WalletFeature { + data object TradingTab : Features(), WalletFeature { + override val key: FeatureKey get() = WalletFeatureKey.IS_TRADING_TAB_ENABLED + + override fun provide(): Boolean { + return isEnabled + } + } + + data object StreamingV2 : Features(), WalletFeature { + override val key: FeatureKey get() = WalletFeatureKey.IS_STREAMING_V2_ENABLED + + override fun provide(): Boolean { + return isEnabled + } + } + + data object WalletKitEnabled : Features(), WalletFeature { + override val key: FeatureKey get() = WalletFeatureKey.IS_WALLETKIT_ENABLED + + override fun provide(): Boolean { + return isEnabled + } + } +} diff --git a/apps/wallet/data/legacy/.gitignore b/apps/wallet/data/legacy/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/apps/wallet/data/legacy/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/apps/wallet/data/legacy/build.gradle.kts b/apps/wallet/data/legacy/build.gradle.kts new file mode 100644 index 000000000..5485a53fd --- /dev/null +++ b/apps/wallet/data/legacy/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("target.android.library") + id("kotlin-parcelize") +} + +dependencies { + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.crashlytics) + + implementation(libs.ton.tvm) + implementation(libs.ton.crypto) + implementation(libs.ton.tlb) + implementation(libs.ton.blockTlb) + implementation(libs.ton.tonapiTl) + implementation(libs.ton.contract) + implementation(projects.lib.blockchain) + implementation(projects.lib.icu) + implementation(projects.tonapi) + + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.purchase) + implementation(projects.apps.wallet.data.rates) + implementation(projects.apps.wallet.data.tokens) + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.staking) +} diff --git a/apps/wallet/data/legacy/src/main/AndroidManifest.xml b/apps/wallet/data/legacy/src/main/AndroidManifest.xml new file mode 100644 index 000000000..227314eeb --- /dev/null +++ b/apps/wallet/data/legacy/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/AssetsEntity.kt b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/AssetsEntity.kt new file mode 100644 index 000000000..7ccb96383 --- /dev/null +++ b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/AssetsEntity.kt @@ -0,0 +1,53 @@ +package com.tonapps.legacy.enteties + +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.icu.Coins +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.wallet.data.token.entities.AccountTokenEntity + + +sealed class AssetsEntity( + val fiat: Coins, +) { + data class Staked(val staked: StakedEntity): AssetsEntity(staked.fiatBalance) { + val isTonstakers: Boolean + get() = staked.isTonstakers + + val liquidToken: BalanceEntity? + get() = staked.liquidToken + + val readyWithdraw: Coins + get() = staked.readyWithdraw + } + + data class Token( + val token: AccountTokenEntity + ): AssetsEntity(token.fiat) { + + val address: String + get() = token.address + + val decimals: Int + get() = token.decimals + + val balance: Coins + get() = token.balance.value + + val symbol: String + get() = token.symbol + + val blockchain: Blockchain + get() = token.balance.token.blockchain + + constructor(token: TokenEntity): this( + AccountTokenEntity.createEmpty(token, "") + ) + } + + data class Currency( + val currency: WalletCurrency, + val coins: Coins = Coins.ZERO + ): AssetsEntity(coins) +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AssetsExtendedEntity.kt b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/AssetsExtendedEntity.kt similarity index 88% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AssetsExtendedEntity.kt rename to apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/AssetsExtendedEntity.kt index b579793ec..848c59bc7 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AssetsExtendedEntity.kt +++ b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/AssetsExtendedEntity.kt @@ -1,11 +1,8 @@ -package com.tonapps.tonkeeper.core.entities +package com.tonapps.legacy.enteties import android.net.Uri -import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.api.entity.TokenEntity.Companion.TON_USDT -import com.tonapps.wallet.api.entity.TokenEntity.Companion.TRC20_USDT +import com.tonapps.blockchain.model.legacy.BalanceEntity import com.tonapps.wallet.data.settings.entities.TokenPrefsEntity import com.tonapps.wallet.data.token.entities.AccountTokenEntity @@ -95,4 +92,7 @@ data class AssetsExtendedEntity( val isTon: Boolean get() = (raw as? AssetsEntity.Token)?.token?.isTon ?: false + + val isTrx: Boolean + get() = token.isTrx } \ No newline at end of file diff --git a/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/SendMetadataEntity.kt b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/SendMetadataEntity.kt new file mode 100644 index 000000000..e1fbbc6f7 --- /dev/null +++ b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/SendMetadataEntity.kt @@ -0,0 +1,7 @@ +package com.tonapps.legacy.enteties + + +data class SendMetadataEntity( + val seqno: Int, + val validUntil: Long, +) \ No newline at end of file diff --git a/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/StakedEntity.kt b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/StakedEntity.kt new file mode 100644 index 000000000..bf71e9ae5 --- /dev/null +++ b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/StakedEntity.kt @@ -0,0 +1,128 @@ +package com.tonapps.legacy.enteties + +import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.icu.Coins +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.wallet.data.rates.RatesRepository +import com.tonapps.wallet.data.staking.StakingPool +import com.tonapps.wallet.data.staking.entities.PoolEntity +import com.tonapps.wallet.data.staking.entities.StakingEntity +import com.tonapps.wallet.data.token.entities.AccountTokenEntity + +data class StakedEntity( + val pool: PoolEntity, + val balance: Coins, + val readyWithdraw: Coins, + val fiatBalance: Coins, + val fiatReadyWithdraw: Coins, + val liquidToken: BalanceEntity? = null, + val pendingDeposit: Coins, + val pendingWithdraw: Coins, + val cycleStart: Long, + val cycleEnd: Long, +) { + + val isTonstakers: Boolean + get() = pool.isTonstakers + + val maxApy: Boolean + get() = pool.maxApy + + companion object { + suspend fun create( + wallet: WalletEntity, + staking: StakingEntity, + tokens: List, + currency: WalletCurrency, + ratesRepository: RatesRepository, + includeEmptyBalances: Boolean = false, + ): List { + val fiatRates = ratesRepository.getTONRates(wallet.network, currency) + val list = mutableListOf() + val pools = if (includeEmptyBalances) getAllPools(staking) else getActivePools(staking, tokens) + for (pool in pools) { + if (pool.implementation == StakingPool.Implementation.LiquidTF) { + val isTonstakersAlready = list.any { it.isTonstakers } + if (isTonstakersAlready) { + continue + } + + val liquidJettonMaster = pool.liquidJettonMaster ?: continue + val token = tokens.find { it.address.equalsAddress(liquidJettonMaster) } + if (token == null) { + if (!includeEmptyBalances) continue + list.add(StakedEntity( + pool = pool, + balance = Coins.ZERO, + fiatBalance = Coins.ZERO, + readyWithdraw = Coins.ZERO, + fiatReadyWithdraw = Coins.ZERO, + liquidToken = null, + pendingDeposit = Coins.ZERO, + pendingWithdraw = Coins.ZERO, + cycleStart = pool.cycleStart, + cycleEnd = pool.cycleEnd, + )) + } else { + val rates = ratesRepository.getRates(wallet.network, WalletCurrency.TON, token.address) + val balance = rates.convert(token.address, token.balance.value) + val readyWithdraw = rates.convert(token.address, staking.getReadyWithdraw(pool)) + val pendingDeposit = rates.convert(token.address, staking.getPendingDeposit(pool)) + val pendingWithdraw = rates.convert(token.address, staking.getPendingWithdraw(pool)) + list.add(StakedEntity( + pool = pool, + balance = balance, + fiatBalance = fiatRates.convertTON(balance), + readyWithdraw = readyWithdraw, + fiatReadyWithdraw = fiatRates.convertTON(readyWithdraw), + liquidToken = token.balance.copy(), + pendingDeposit = pendingDeposit, + pendingWithdraw = pendingWithdraw, + cycleStart = pool.cycleStart, + cycleEnd = pool.cycleEnd, + )) + } + } else { + val balance = staking.getAmount(pool) + val readyWithdraw = staking.getReadyWithdraw(pool) + val pendingDeposit = staking.getPendingDeposit(pool) + + list.add(StakedEntity( + pool = pool, + balance = balance, + fiatBalance = fiatRates.convertTON(balance), + readyWithdraw = readyWithdraw, + fiatReadyWithdraw = fiatRates.convertTON(readyWithdraw), + pendingDeposit = pendingDeposit, + pendingWithdraw = staking.getPendingWithdraw(pool), + cycleStart = pool.cycleStart, + cycleEnd = pool.cycleEnd, + )) + } + } + return list + } + + private fun getAllPools(staking: StakingEntity): List { + return staking.pools.flatMap { it.pools } + } + + private fun getActivePools( + staking: StakingEntity, + tokens: List + ): List { + val pools = mutableListOf() + for (token in tokens.filter { staking.poolsJettonAddresses.contains(it.address) }) { + staking.findPoolByTokenAddress(token.address)?.let { pools.add(it) } + } + + for (info in staking.info) { + staking.findPoolByAddress(info.pool)?.let { pools.add(it) } + } + return pools + } + } + +} \ No newline at end of file diff --git a/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/WalletExtendedEntity.kt b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/WalletExtendedEntity.kt new file mode 100644 index 000000000..134d19515 --- /dev/null +++ b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/WalletExtendedEntity.kt @@ -0,0 +1,13 @@ +package com.tonapps.legacy.enteties + +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.wallet.data.settings.entities.WalletPrefsEntity + +data class WalletExtendedEntity( + val raw: WalletEntity, + val prefs: WalletPrefsEntity, +) { + + val index: Int + get() = prefs.index +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/WalletPurchaseMethodEntity.kt b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/WalletPurchaseMethodEntity.kt similarity index 83% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/WalletPurchaseMethodEntity.kt rename to apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/WalletPurchaseMethodEntity.kt index 19c8c6e98..d2ef08850 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/WalletPurchaseMethodEntity.kt +++ b/apps/wallet/data/legacy/src/main/java/com/tonapps/legacy/enteties/WalletPurchaseMethodEntity.kt @@ -1,12 +1,10 @@ -package com.tonapps.tonkeeper.core.entities +package com.tonapps.legacy.enteties import android.net.Uri import android.os.Parcelable -import android.util.Log import com.tonapps.wallet.api.entity.ConfigEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.purchase.entity.PurchaseMethodEntity -import com.tonapps.wallet.data.purchase.entity.PurchaseMethodEntity.SuccessUrlPattern import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.ton.crypto.digest.sha512 @@ -21,10 +19,6 @@ data class WalletPurchaseMethodEntity( val config: ConfigEntity, ): Parcelable { - @IgnoredOnParcel - val successUrlPattern: SuccessUrlPattern? - get() = method.successUrlPattern - @IgnoredOnParcel val uri: Uri by lazy { val address = wallet.address @@ -33,7 +27,6 @@ data class WalletPurchaseMethodEntity( } private companion object { - fun replaceUrl( url: String, address: String, diff --git a/apps/wallet/data/passcode/build.gradle.kts b/apps/wallet/data/passcode/build.gradle.kts index 5a397012c..5bf9945b8 100644 --- a/apps/wallet/data/passcode/build.gradle.kts +++ b/apps/wallet/data/passcode/build.gradle.kts @@ -1,21 +1,16 @@ plugins { - id("com.tonapps.wallet.data") -} - -android { - namespace = Build.namespacePrefix("wallet.data.passcode") + id("target.android.library") } dependencies { + implementation(libs.androidx.biometric) + implementation(libs.koin.core) - implementation(libs.androidX.biometric) - - implementation(project(ProjectModules.UIKit.core)) - - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.account)) - implementation(project(ProjectModules.Wallet.Data.settings)) - implementation(project(ProjectModules.Wallet.Data.rn)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.security)) + implementation(projects.ui.uikit.core) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.rn) + implementation(projects.lib.extensions) + implementation(projects.lib.security) } diff --git a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/LockScreen.kt b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/LockScreen.kt index def785160..a32f4a02e 100644 --- a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/LockScreen.kt +++ b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/LockScreen.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.passcode import android.content.Context -import android.util.Log +import com.tonapps.log.L import androidx.biometric.BiometricPrompt import com.tonapps.wallet.data.settings.SettingsRepository import kotlinx.coroutines.Dispatchers diff --git a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeHelper.kt b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeHelper.kt index 6616f5f63..96c76be23 100644 --- a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeHelper.kt +++ b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeHelper.kt @@ -2,7 +2,7 @@ package com.tonapps.wallet.data.passcode import android.content.Context import android.content.res.Configuration -import android.util.Log +import com.tonapps.log.L import com.tonapps.wallet.data.account.AccountRepository import com.tonapps.wallet.data.passcode.source.PasscodeStore import com.tonapps.wallet.data.settings.SettingsRepository @@ -46,5 +46,4 @@ class PasscodeHelper( } return false } - -} \ No newline at end of file +} diff --git a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeManager.kt b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeManager.kt index 0ba3b75a9..3e06d4f15 100644 --- a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeManager.kt +++ b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/PasscodeManager.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.passcode import android.content.Context -import android.util.Log +import com.tonapps.log.L import androidx.biometric.BiometricPrompt import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.extensions.logError @@ -245,13 +245,4 @@ class PasscodeManager( false } } - - fun confirmationFlow(context: Context, title: String) = flow { - val valid = confirmation(context, title) - if (!valid) { - throw Exception("failed to request passcode") - } else { - emit(Unit) - } - } } \ No newline at end of file diff --git a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/dialog/PasscodeDialog.kt b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/dialog/PasscodeDialog.kt index 3a2f7181a..5a9fc0a7e 100644 --- a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/dialog/PasscodeDialog.kt +++ b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/dialog/PasscodeDialog.kt @@ -6,7 +6,8 @@ import android.view.View import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.tonapps.wallet.data.passcode.PasscodeHelper -import com.tonapps.wallet.data.passcode.R +import com.tonapps.apps.wallet.data.passcode.R +import com.tonapps.lib.log.BuildConfig import com.tonapps.wallet.data.passcode.ui.PasscodeView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -65,7 +66,7 @@ class PasscodeDialog( override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setSecure(true) + setSecure(!BuildConfig.DEBUG) setAppearanceLight(helper.isLightTheme) } diff --git a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/source/PasscodeStore.kt b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/source/PasscodeStore.kt index fc9bbbc58..471526ddb 100644 --- a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/source/PasscodeStore.kt +++ b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/source/PasscodeStore.kt @@ -2,7 +2,7 @@ package com.tonapps.wallet.data.passcode.source import android.content.Context import android.content.SharedPreferences -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.clear import com.tonapps.extensions.putString import com.tonapps.extensions.remove @@ -13,21 +13,16 @@ import kotlinx.coroutines.withContext class PasscodeStore(context: Context) { companion object { - const val CODE_LENGTH = 4 - private const val NAME = "passcode" private const val CODE_KEY = "code" private const val KEY_ALIAS = "_com_tonapps_passcode_master_key_" } - private val keyValue: SharedPreferences by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, NAME) } + private val keyValue by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { Security.pref(context, KEY_ALIAS, NAME) } val hasPinCode: Boolean - get() = keyValue.contains(CODE_KEY) && !keyValue.getString(CODE_KEY, null).isNullOrBlank() + get() = !keyValue.get(CODE_KEY).isNullOrBlank() - suspend fun setPinCode(code: String) = withContext(Dispatchers.IO) { - keyValue.putString(CODE_KEY, code) - } suspend fun clearPinCode() = withContext(Dispatchers.IO) { keyValue.clear() @@ -40,14 +35,16 @@ class PasscodeStore(context: Context) { } return false } + suspend fun setPinCode(code: String) = withContext(Dispatchers.IO) { + keyValue.put(CODE_KEY, code) + } suspend fun compare(code: String): Boolean = withContext(Dispatchers.IO) { - val savedCode = keyValue.getString(CODE_KEY, null) + val savedCode = keyValue.get(CODE_KEY) if (savedCode.isNullOrBlank()) { false } else { savedCode == code } } - -} \ No newline at end of file +} diff --git a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/ui/PasscodeView.kt b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/ui/PasscodeView.kt index 9d40a3df1..70b98c901 100644 --- a/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/ui/PasscodeView.kt +++ b/apps/wallet/data/passcode/src/main/java/com/tonapps/wallet/data/passcode/ui/PasscodeView.kt @@ -3,7 +3,7 @@ package com.tonapps.wallet.data.passcode.ui import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatTextView -import com.tonapps.wallet.data.passcode.R +import com.tonapps.apps.wallet.data.passcode.R import uikit.widget.ColumnLayout import uikit.widget.NumPadView import uikit.widget.PinInputView diff --git a/apps/wallet/data/plugins/build.gradle.kts b/apps/wallet/data/plugins/build.gradle.kts index 037632319..aa14b23f3 100644 --- a/apps/wallet/data/plugins/build.gradle.kts +++ b/apps/wallet/data/plugins/build.gradle.kts @@ -1,16 +1,14 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } - -android { - namespace = Build.namespacePrefix("wallet.data.plugins") -} - dependencies { - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Wallet.Data.core)) + implementation(libs.koin.core) + + implementation(projects.tonapi.legacy) + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.lib.blockchain) } diff --git a/apps/wallet/data/plugins/src/main/java/com/tonapps/wallet/data/plugins/PluginsRepository.kt b/apps/wallet/data/plugins/src/main/java/com/tonapps/wallet/data/plugins/PluginsRepository.kt index c979a7ab9..d84b7dd55 100644 --- a/apps/wallet/data/plugins/src/main/java/com/tonapps/wallet/data/plugins/PluginsRepository.kt +++ b/apps/wallet/data/plugins/src/main/java/com/tonapps/wallet/data/plugins/PluginsRepository.kt @@ -1,6 +1,7 @@ package com.tonapps.wallet.data.plugins import android.content.Context +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.api.API import com.tonapps.wallet.data.core.BlobDataSource import io.Serializer @@ -30,10 +31,10 @@ class PluginsRepository( suspend fun getPlugins( accountId: String, - testnet: Boolean, + network: TonNetwork, refresh: Boolean = false, ): List = withContext(Dispatchers.IO) { - val key = cacheKey(accountId, testnet) + val key = cacheKey(accountId, network) if (!refresh) { val cached = getCache(key) if (cached != null) { @@ -41,7 +42,7 @@ class PluginsRepository( } } try { - val wallet = api.wallet(testnet).getWalletInfo(accountId) + val wallet = api.wallet(network).getWalletInfo(accountId) val plugins = wallet.plugins setCache(key, plugins) _updatedFlow.emit(Unit) @@ -55,8 +56,8 @@ class PluginsRepository( } } - private fun cacheKey(accountId: String, testnet: Boolean): String { - return if (testnet) "${accountId}_testnet" else accountId + private fun cacheKey(accountId: String, network: TonNetwork): String { + return "${accountId}_${network.name.lowercase()}" } override fun onMarshall(data: List) = Serializer.toJSON(data).toByteArray() diff --git a/apps/wallet/data/purchase/build.gradle.kts b/apps/wallet/data/purchase/build.gradle.kts index 1dd88c190..ab87107d4 100644 --- a/apps/wallet/data/purchase/build.gradle.kts +++ b/apps/wallet/data/purchase/build.gradle.kts @@ -1,18 +1,15 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") kotlin("plugin.serialization") } -android { - namespace = Build.namespacePrefix("wallet.data.purchase") -} - dependencies { - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Module.tonApi)) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.api) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.tonapi.legacy) api(libs.ton.tvm) api(libs.ton.crypto) @@ -20,4 +17,5 @@ dependencies { api(libs.ton.blockTlb) api(libs.ton.tonapiTl) api(libs.ton.contract) + implementation(libs.koin.core) } diff --git a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/OnRampUtils.kt b/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/OnRampUtils.kt deleted file mode 100644 index a70b8e48c..000000000 --- a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/OnRampUtils.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.tonapps.wallet.data.purchase - -import com.tonapps.wallet.data.core.currency.WalletCurrency -import kotlin.math.abs -import kotlin.math.ceil -import kotlin.math.floor -import kotlin.math.log10 -import kotlin.math.pow - -object OnRampUtils { - - fun normalizeType(currency: WalletCurrency): String? { - if (currency.fiat) { - return null - } else if (3 >= currency.address.length) { - return "native" - } - return when (currency.chain.name.lowercase()) { - "etc", "erc-20" -> "erc-20" - "ton", "jetton" -> "jetton" - "tron", "trc-20" -> "trc-20" - "sol", "spl" -> "spl" - "bnb", "bep-20" -> "bep-20" - "avalanche" -> "avalanche" - "arbitrum" -> "arbitrum" - else -> null - } - } - - fun smartRoundUp(value: Double): Double { - if (value <= 0.0) return value - val mag = 10.0.pow(floor(log10(value))) - val ticks = doubleArrayOf(1.0, 1.5, 2.0, 5.0) - for (t in ticks) { - val target = t * mag - if (value <= target + 1e-12) { - return if (abs(value - target) < 1e-9) value else target - } - } - return 10.0 * mag - } -} \ No newline at end of file diff --git a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/PurchaseRepository.kt b/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/PurchaseRepository.kt index 3ed17d589..ee6ff2472 100644 --- a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/PurchaseRepository.kt +++ b/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/PurchaseRepository.kt @@ -1,16 +1,14 @@ package com.tonapps.wallet.data.purchase import android.content.Context -import android.util.Log -import com.google.firebase.BuildConfig -import com.tonapps.extensions.getParcelable -import com.tonapps.extensions.prefs -import com.tonapps.extensions.putParcelable +import com.tonapps.extensions.CacheKey +import com.tonapps.extensions.TimedCacheMemory +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toParcel +import com.tonapps.log.L import com.tonapps.wallet.api.API import com.tonapps.wallet.data.core.BlobDataSource -import com.tonapps.wallet.data.core.currency.WalletCurrency import com.tonapps.wallet.data.purchase.entity.MerchantEntity import com.tonapps.wallet.data.purchase.entity.OnRamp import com.tonapps.wallet.data.purchase.entity.PurchaseCategoryEntity @@ -19,15 +17,10 @@ import com.tonapps.wallet.data.purchase.entity.PurchaseMethodEntity import io.Serializer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext -import org.ton.crypto.digest.sha512 -import org.ton.crypto.hex import java.util.Locale -import java.util.UUID import java.util.concurrent.TimeUnit class PurchaseRepository( @@ -40,17 +33,18 @@ class PurchaseRepository( timeout = TimeUnit.DAYS.toMillis(1) ) { + sealed interface Keys : CacheKey { + data object Merchants : Keys + } + + private val timedCache = TimedCacheMemory() + private val onRampCache = simple(context, "onRamp", TimeUnit.DAYS.toMillis(14)) private val merchantsCache = simpleJSON>(context,"merchants", TimeUnit.DAYS.toMillis(1)) fun onRampDataFlow() = flow { - getOnRampDataCache()?.let { - emit(it) - } - - fetchOnRampDataCache()?.let { - emit(it) - } + getOnRampDataCache()?.let { emit(it) } + fetchOnRampDataCache()?.let { emit(it) } }.flowOn(Dispatchers.IO) private fun loadOnRampMerchants(): List { @@ -74,42 +68,36 @@ class PurchaseRepository( suspend fun getPaymentMethods(currency: String): List = withContext(Dispatchers.IO) { try { val data = api.getOnRampPaymentMethods(currency) ?: throw Exception("No payment methods found for country: ${api.country}") - Log.d("PurchaseRepositoryLog", "getPaymentMethods: $data") + L.d("PurchaseRepositoryLog", "getPaymentMethods: $data") Serializer.JSON.decodeFromString>(data) } catch (e: Throwable) { - Log.e("PurchaseRepositoryLog", "error", e) + L.e("PurchaseRepositoryLog", "error", e) emptyList() } } - private suspend fun loadOnRampData(): OnRamp.Data? = withContext(Dispatchers.IO) { - val data = api.getOnRampData() ?: return@withContext null - try { - Serializer.fromJSON(data) - } catch (e: Throwable) { - null - } - } - - private fun getOnRampDataCache(): OnRamp.Data? { + fun getOnRampDataCache(): OnRamp.Data? { val cacheKey = "data_${api.country}" return onRampCache.getCache(cacheKey) } - private suspend fun fetchOnRampDataCache(): OnRamp.Data? { - val data = loadOnRampData() ?: return null - val cacheKey = "data_${api.country}" - onRampCache.setCache(cacheKey, data) - return data + suspend fun fetchOnRampDataCache(): OnRamp.Data? { + return runCatching { + timedCache.getOrLoad(Keys.Merchants) { + Serializer.fromJSON( + string = api.getOnRampData() + .orEmpty() + ) + } + }.getOrNull() } - fun get( - testnet: Boolean, + network: TonNetwork, country: String, locale: Locale, ): Pair, List>? { - val data = get(testnet, locale) ?: return null + val data = get(network, locale) ?: return null val methods = data.getCountry(country).methods return filterMethods(data.buy, methods) to filterMethods(data.sell, methods) } @@ -141,50 +129,29 @@ class PurchaseRepository( return list } - fun getMethod(id: String, testnet: Boolean, locale: Locale): PurchaseMethodEntity? { - val data = get(testnet, locale) ?: return null + fun getMethod(id: String, network: TonNetwork, locale: Locale): PurchaseMethodEntity? { + val data = get(network, locale) ?: return null val methods = (data.buy + data.sell).map { it.items }.flatten() return methods.find { it.id == id } } - private fun get(testnet: Boolean, locale: Locale): PurchaseDataEntity? { - val key = cacheKey(testnet, locale) + private fun get(network: TonNetwork, locale: Locale): PurchaseDataEntity? { + val key = cacheKey(network, locale) var data = getCache(key) if (data == null) { - data = load(testnet, locale) ?: return null + data = load(network, locale) ?: return null setCache(key, data) } return data } - private fun load(testnet: Boolean, locale: Locale): PurchaseDataEntity? { - val json = api.getFiatMethods(testnet, locale) ?: return null + private fun load(network: TonNetwork, locale: Locale): PurchaseDataEntity? { + val json = api.getFiatMethods(network, locale) ?: return null return PurchaseDataEntity(json) } - private fun cacheKey(testnet: Boolean, locale: Locale): String { - val prefix = if (testnet) "testnet" else "mainnet" - return "$prefix-${locale.language}" - } - - fun replaceUrl( - url: String, - address: String, - currency: String - ): String { - var replacedUrl = url.replace("{ADDRESS}", address) - replacedUrl = replacedUrl.replace("{CUR_FROM}", currency) - replacedUrl = replacedUrl.replace("{CUR_TO}", "TON") - - if (replacedUrl.contains("TX_ID")) { - val mercuryoSecret = api.config.mercuryoSecret - val signature = hex(sha512((address+mercuryoSecret).toByteArray())) - val tx = "mercuryo_" + UUID.randomUUID().toString() - replacedUrl = replacedUrl.replace("{TX_ID}", tx) - replacedUrl = replacedUrl.replace("=TON&", "=TONCOIN&") - replacedUrl += "&signature=$signature" - } - return replacedUrl + private fun cacheKey(network: TonNetwork, locale: Locale): String { + return "${network.name.lowercase()}-${locale.language}" } override fun onMarshall(data: PurchaseDataEntity) = data.toByteArray() diff --git a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRamp.kt b/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRamp.kt index e4a258d40..28651b360 100644 --- a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRamp.kt +++ b/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRamp.kt @@ -1,15 +1,14 @@ +@file:SuppressLint("UnsafeOptInUsageError") package com.tonapps.wallet.data.purchase.entity +import android.annotation.SuppressLint import android.os.Parcelable -import android.util.Log -import com.tonapps.wallet.data.core.currency.WalletCurrency -import com.tonapps.wallet.data.purchase.OnRampUtils -import kotlinx.coroutines.flow.map +import com.tonapps.extensions.lazyUnsafe +import com.tonapps.blockchain.model.legacy.WalletCurrency import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlin.ranges.contains @Parcelize sealed class OnRamp: Parcelable { @@ -23,7 +22,22 @@ sealed class OnRamp: Parcelable { data class Method( val image: String, val type: String - ): OnRamp() + ): OnRamp() { + + @IgnoredOnParcel + val title: String by lazyUnsafe { + when (type) { + "card" -> "Credit Card" + "revolut" -> "Revolut" + "google_pay" -> "Google Pay" + "paypal" -> "PayPal" + else -> type.replace("_", " ") + .replaceFirstChar { it.uppercaseChar() } + } + } + + + } @Serializable @Parcelize @@ -70,10 +84,6 @@ sealed class OnRamp: Parcelable { val max: Double? by lazy { merchants.mapNotNull { it.limits?.max }.maxOrNull() } - - fun containsMerchant(merchant: String): Boolean { - return merchants.any { it.slug.equals(merchant, true) } - } } @Serializable @@ -115,7 +125,9 @@ sealed class OnRamp: Parcelable { @IgnoredOnParcel val availableFiatSlugs: List by lazy { - assets.filter { it.type.equals("fiat", ignoreCase = true) }.map { it.slug }.distinct() + assets.filter { it.type.equals("fiat", ignoreCase = true) } + .map { it.slug } + .distinct() } @IgnoredOnParcel @@ -125,32 +137,41 @@ sealed class OnRamp: Parcelable { @IgnoredOnParcel val externalCurrency: List by lazy { - val crypto = assets.filter { - it.type.equals("crypto", ignoreCase = true) - }.filter { it.image?.ifBlank { null } != null } + val crypto = assets + .filter { + it.type.equals("crypto", ignoreCase = true) && !it.image.isNullOrBlank() + } val list = mutableListOf() for (item in crypto) { - val method = (item.inputMethods.firstOrNull() ?: item.outputMethods.firstOrNull()) ?: continue + val method = item.inputMethods.firstOrNull() + ?: item.outputMethods.firstOrNull() + ?: continue + if (method.type == "native") { val currency = WalletCurrency.of(item.slug) ?: continue + if (currency.isTONChain) { continue } + list.add(currency) } else { val chainAddress = method.address ?: continue val chain = WalletCurrency.createChain(method.type, chainAddress) + if (chain is WalletCurrency.Chain.TON) { continue } + val currency = WalletCurrency( code = item.slug, title = item.slug, chain = chain, iconUrl = item.image ) + list.add(currency) } } @@ -165,19 +186,6 @@ sealed class OnRamp: Parcelable { WalletCurrency.sort(list) } - fun resolveNetwork(input: Boolean, currency: WalletCurrency): String? { - if (currency.fiat) { - return null - } - val assets = assets.filter { asset -> - asset.slug.equals(currency.code, true) - } - val methods = assets.flatMap { - if (input) it.inputMethods else it.outputMethods - } - return OnRampUtils.normalizeType(currency) ?: methods.firstOrNull()?.type - } - fun findValidPairs(from: String, to: String): Pairs { val pairs = allowedPairs.filter { pair -> (pair.from.symbol.equals(from, true) && pair.to.symbol.equals(to, true)) diff --git a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRampCurrencies.kt b/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRampCurrencies.kt index b204f582e..40b55ca55 100644 --- a/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRampCurrencies.kt +++ b/apps/wallet/data/purchase/src/main/java/com/tonapps/wallet/data/purchase/entity/OnRampCurrencies.kt @@ -1,6 +1,5 @@ package com.tonapps.wallet.data.purchase.entity -import android.util.Log import com.tonapps.wallet.data.purchase.entity.OnRamp.Asset data class OnRampCurrencies( @@ -10,19 +9,6 @@ data class OnRampCurrencies( ) { companion object { - - private val supportedChain = listOf( - "TON" - ) - - private val supportedTONAssets = listOf( - "TON", "USDT", "CATI", "MAJOR", "HMSTR", "PEPE" - ) - - private val supportedCrypto = listOf( - "TON", "USDT" - ) - private fun create(assets: List, type: String): OnRampCurrencies { val fiatAssets = assets.filter { it.type == type } val inputs = fiatAssets.map { it.inputMethods }.flatten().map { it.type }.distinct() diff --git a/apps/wallet/data/rates/build.gradle.kts b/apps/wallet/data/rates/build.gradle.kts index 9a821a184..2c96d764a 100644 --- a/apps/wallet/data/rates/build.gradle.kts +++ b/apps/wallet/data/rates/build.gradle.kts @@ -1,18 +1,16 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.rates") -} - dependencies { - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.icu)) + implementation(libs.koin.core) + + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.tonapi.legacy) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) } diff --git a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateData.kt b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateData.kt index ae6fb4cb1..0061065fc 100644 --- a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateData.kt +++ b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateData.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.rates import com.tonapps.icu.Coins -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency data class RateData( val from: WalletCurrency, diff --git a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateManager.kt b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateManager.kt index 186bad801..09c1c7e83 100644 --- a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateManager.kt +++ b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RateManager.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.rates import com.tonapps.icu.Coins -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode diff --git a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RatesRepository.kt b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RatesRepository.kt index 050fd6eb2..5ff7f7367 100644 --- a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RatesRepository.kt +++ b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/RatesRepository.kt @@ -1,12 +1,11 @@ package com.tonapps.wallet.data.rates import android.content.Context -import android.util.Log -import com.google.firebase.annotations.concurrent.Background import com.tonapps.icu.Coins +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.rates.entity.RateDiffEntity import com.tonapps.wallet.data.rates.entity.RateEntity import com.tonapps.wallet.data.rates.entity.RatesEntity @@ -25,6 +24,7 @@ class RatesRepository( private val manager = RateManager() suspend fun getRate( + network: TonNetwork, from: WalletCurrency, to: WalletCurrency, baseCurrency: WalletCurrency = WalletCurrency.USD @@ -33,7 +33,7 @@ class RatesRepository( val all = listOf(from, to, baseCurrency) val currency = all.first { it.fiat } val tokens = all.filter { !it.fiat } - val response = fetchRates(currency.code, tokens.map { it.tokenQuery }) + val response = fetchRates(network, currency.code, tokens.map { it.tokenQuery }) for (token in tokens) { val tokenRates = response[token.tokenQuery] ?: continue val price = tokenRates.prices?.get(currency.code)?.let { @@ -48,31 +48,32 @@ class RatesRepository( } suspend fun convert( + network: TonNetwork, amount: Coins, from: WalletCurrency, to: WalletCurrency ): Coins { - getRate(from, to) + getRate(network, from, to) return manager.convert(amount, from, to) ?: Coins.ZERO } - suspend fun updateAll(currency: WalletCurrency, tokens: List) = withContext(Dispatchers.IO) { - load(currency, tokens.take(100).toMutableList()) + suspend fun updateAll(network: TonNetwork, currency: WalletCurrency, tokens: List) = withContext(Dispatchers.IO) { + load(network, currency, tokens.take(100).toMutableList()) } - suspend fun updateAll(currency: WalletCurrency) = withContext(Dispatchers.IO) { - updateAll(currency, localDataSource.get(currency).tokens) + suspend fun updateAll(network: TonNetwork, currency: WalletCurrency) = withContext(Dispatchers.IO) { + updateAll(network, currency, localDataSource.get(network, currency).tokens) } - fun cache(currency: WalletCurrency, tokens: List): RatesEntity { - return localDataSource.get(currency).filter(tokens) + fun cache(network: TonNetwork, currency: WalletCurrency, tokens: List): RatesEntity { + return localDataSource.get(network, currency).filter(tokens) } - fun load(currency: WalletCurrency, token: String) { - load(currency, mutableListOf(token)) + fun load(network: TonNetwork, currency: WalletCurrency, token: String) { + load(network, currency, mutableListOf(token)) } - fun load(currency: WalletCurrency, tokens: MutableList) { + fun load(network: TonNetwork, currency: WalletCurrency, tokens: MutableList) { if (!tokens.contains(TokenEntity.TON.address)) { tokens.add(TokenEntity.TON.address) } @@ -81,23 +82,23 @@ class RatesRepository( } val rates = mutableMapOf() for (chunk in tokens.chunked(100)) { - runCatching { fetchRates(currency.code, chunk) }.onSuccess(rates::putAll) + runCatching { fetchRates(network, currency.code, chunk) }.onSuccess(rates::putAll) } val usdtRate = rates[TokenEntity.USDT.address] usdtRate?.let { rates.put(TokenEntity.TRON_USDT.address, usdtRate) } - insertRates(currency, rates) + insertRates(network, currency, rates) } - private fun fetchRates(code: String, tokens: List): Map { + private fun fetchRates(network: TonNetwork, code: String, tokens: List): Map { if (tokens.size > 100) { throw IllegalArgumentException("Too many tokens requested: ${tokens.size}") } - return api.getRates(code, tokens) ?: throw IllegalStateException("Failed to fetch rates for $code with tokens: $tokens") + return api.getRates(network, code, tokens) ?: throw IllegalStateException("Failed to fetch rates for $code with tokens: $tokens") } - fun insertRates(currency: WalletCurrency, rates: Map) { + fun insertRates(network: TonNetwork, currency: WalletCurrency, rates: Map) { if (rates.isEmpty()) { return } @@ -114,31 +115,32 @@ class RatesRepository( diff = RateDiffEntity(currency, value), )) } - localDataSource.add(currency, entities) + localDataSource.add(network, currency, entities) } - private fun getCachedRates(currency: WalletCurrency, tokens: List): RatesEntity { - return localDataSource.get(currency).filter(tokens) + private fun getCachedRates(network: TonNetwork, currency: WalletCurrency, tokens: List): RatesEntity { + return localDataSource.get(network, currency).filter(tokens) } - suspend fun getRates(currency: WalletCurrency, token: String): RatesEntity { - return getRates(currency, listOf(token)) + suspend fun getRates(network: TonNetwork, currency: WalletCurrency, token: String): RatesEntity { + return getRates(network, currency, listOf(token)) } - suspend fun getTONRates(currency: WalletCurrency): RatesEntity { - return getRates(currency, TokenEntity.TON.address) + suspend fun getTONRates(network: TonNetwork, currency: WalletCurrency): RatesEntity { + return getRates(network, currency, TokenEntity.TON.address) } suspend fun getRates( + network: TonNetwork, currency: WalletCurrency, tokens: List ): RatesEntity = withContext(Dispatchers.IO) { - val rates = getCachedRates(currency, tokens) + val rates = getCachedRates(network, currency, tokens) if (rates.hasTokens(tokens)) { rates } else { - load(currency, tokens.toMutableList()) - getCachedRates(currency, tokens) + load(network, currency, tokens.toMutableList()) + getCachedRates(network, currency, tokens) } } -} \ No newline at end of file +} diff --git a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateDiffEntity.kt b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateDiffEntity.kt index 371748334..0ee917807 100644 --- a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateDiffEntity.kt +++ b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateDiffEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.rates.entity import android.os.Parcelable -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import io.tonapi.models.TokenRates import kotlinx.parcelize.Parcelize diff --git a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateEntity.kt b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateEntity.kt index bf7a31697..cdd2e9f6a 100644 --- a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateEntity.kt +++ b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RateEntity.kt @@ -2,7 +2,7 @@ package com.tonapps.wallet.data.rates.entity import android.os.Parcelable import com.tonapps.icu.Coins -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import kotlinx.parcelize.Parcelize @Parcelize diff --git a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RatesEntity.kt b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RatesEntity.kt index 50fb78f7f..43f7e5070 100644 --- a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RatesEntity.kt +++ b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/entity/RatesEntity.kt @@ -1,10 +1,10 @@ package com.tonapps.wallet.data.rates.entity import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency import kotlinx.parcelize.Parcelize import java.math.BigDecimal import java.math.MathContext diff --git a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/source/BlobDataSource.kt b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/source/BlobDataSource.kt index b1473dbc8..439ccc1a2 100644 --- a/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/source/BlobDataSource.kt +++ b/apps/wallet/data/rates/src/main/java/com/tonapps/wallet/data/rates/source/BlobDataSource.kt @@ -1,10 +1,11 @@ package com.tonapps.wallet.data.rates.source import android.content.Context +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toParcel import com.tonapps.wallet.data.core.BlobDataSource -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.rates.entity.RateEntity import com.tonapps.wallet.data.rates.entity.RatesEntity import java.util.concurrent.TimeUnit @@ -19,21 +20,26 @@ internal class BlobDataSource(context: Context): BlobDataSource( override fun onMarshall(data: RatesEntity) = data.toByteArray() - fun get(currency: WalletCurrency): RatesEntity { - val rates = getCache(currency.code) ?: RatesEntity.empty(currency) + private fun cacheKey(network: TonNetwork, currency: WalletCurrency): String { + return "${network.value}_${currency.code}" + } + + fun get(network: TonNetwork, currency: WalletCurrency): RatesEntity { + val key = cacheKey(network, currency) + val rates = getCache(key) ?: RatesEntity.empty(currency) if (rates.isEmpty) { - clearCache(currency.code) + clearCache(key) return rates } return rates.copy() } - fun add(currency: WalletCurrency, list: List) { + fun add(network: TonNetwork, currency: WalletCurrency, list: List) { if (list.isEmpty()) { return } - val rates = get(currency).merge(list) - setCache(currency.code, rates) + val rates = get(network, currency).merge(list) + setCache(cacheKey(network, currency), rates) } } \ No newline at end of file diff --git a/apps/wallet/data/rn/build.gradle.kts b/apps/wallet/data/rn/build.gradle.kts index 4bef1d33e..fafa0b7af 100644 --- a/apps/wallet/data/rn/build.gradle.kts +++ b/apps/wallet/data/rn/build.gradle.kts @@ -1,22 +1,19 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.rn") -} - dependencies { api(platform(libs.firebase.bom)) api(libs.firebase.crashlytics) - implementation(libs.androidX.biometric) + implementation(libs.androidx.biometric) implementation(libs.ton.crypto) + implementation(libs.koin.core) - implementation(project(ProjectModules.Lib.sqlite)) - implementation(project(ProjectModules.Lib.security)) - implementation(project(ProjectModules.Lib.extensions)) + implementation(projects.lib.sqlite) + implementation(projects.lib.security) + implementation(projects.lib.extensions) } diff --git a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNLegacy.kt b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNLegacy.kt index caa9fa2cd..573706ff6 100644 --- a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNLegacy.kt +++ b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNLegacy.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.rn import android.content.Context -import android.util.Log +import com.tonapps.log.L import androidx.fragment.app.FragmentActivity import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.extensions.bestMessage @@ -22,7 +22,7 @@ import org.json.JSONObject class RNLegacy( context: Context, - private val scope: CoroutineScope + scope: CoroutineScope ) { companion object { diff --git a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSeedStorage.kt b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSeedStorage.kt index 7dee88552..b25e04b9f 100644 --- a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSeedStorage.kt +++ b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSeedStorage.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.rn import android.content.Context -import android.util.Log +import com.tonapps.log.L import androidx.fragment.app.FragmentActivity import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.extensions.asJSON diff --git a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSql.kt b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSql.kt index ed3d69968..adf42fe1d 100644 --- a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSql.kt +++ b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/RNSql.kt @@ -111,5 +111,4 @@ internal class RNSql(context: Context): SQLiteHelper(context, DATABASE_NAME, DAT FirebaseCrashlytics.getInstance().recordException(e) } } - -} \ No newline at end of file +} diff --git a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/data/RNWallet.kt b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/data/RNWallet.kt index 4537b1537..cc353d63d 100644 --- a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/data/RNWallet.kt +++ b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/data/RNWallet.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.rn.data import android.util.ArrayMap -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.color import org.json.JSONArray import org.json.JSONObject diff --git a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/SecureStoreModule.kt b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/SecureStoreModule.kt index 70c660caf..4c7db5644 100644 --- a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/SecureStoreModule.kt +++ b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/SecureStoreModule.kt @@ -4,7 +4,7 @@ import android.content.Context import android.content.SharedPreferences import android.preference.PreferenceManager import android.security.keystore.KeyPermanentlyInvalidatedException -import android.util.Log +import com.tonapps.log.L import androidx.fragment.app.FragmentActivity import com.tonapps.extensions.asJSON import com.tonapps.wallet.data.rn.expo.encryptors.AESEncryptor @@ -100,7 +100,7 @@ internal class SecureStoreModule( } } } catch (e: KeyPermanentlyInvalidatedException) { - Log.w(TAG, "The requested key has been permanently invalidated. Returning null") + L.w(TAG, "The requested key has been permanently invalidated. Returning null") return null } catch (e: BadPaddingException) { throw (DecryptException("Could not decrypt the value with provided keychain $legacyReadFailedWarning", key, options.keychainService, e)) @@ -149,7 +149,7 @@ internal class SecureStoreModule( } } catch (e: KeyPermanentlyInvalidatedException) { if (!keyIsInvalidated) { - Log.w(TAG, "Key has been invalidated, retrying with the key deleted") + L.w(TAG, "Key has been invalidated, retrying with the key deleted") return setItemImpl(key, value, options, true) } throw EncryptException("Encryption Failed. The key $key has been permanently invalidated and cannot be reinitialized", key, options.keychainService, e) @@ -223,7 +223,7 @@ internal class SecureStoreModule( // so we shouldn't delete them. if (requireAuthentication && keychainService == entryKeychainService) { sharedPreferences.edit().remove(key).apply() - Log.w(TAG, "Removing entry: $key due to the encryption key being deleted") + L.w(TAG, "Removing entry: $key due to the encryption key being deleted") } } } diff --git a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/encryptors/AESEncryptor.kt b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/encryptors/AESEncryptor.kt index 443000df8..f790e077d 100644 --- a/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/encryptors/AESEncryptor.kt +++ b/apps/wallet/data/rn/src/main/java/com/tonapps/wallet/data/rn/expo/encryptors/AESEncryptor.kt @@ -4,7 +4,6 @@ import android.annotation.TargetApi import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 -import android.util.Log import com.tonapps.wallet.data.rn.expo.AuthenticationHelper import com.tonapps.wallet.data.rn.expo.DecryptException import com.tonapps.wallet.data.rn.expo.SecureStoreModule diff --git a/apps/wallet/data/settings/build.gradle.kts b/apps/wallet/data/settings/build.gradle.kts index 2111fb392..3ab5b66c9 100644 --- a/apps/wallet/data/settings/build.gradle.kts +++ b/apps/wallet/data/settings/build.gradle.kts @@ -1,14 +1,15 @@ plugins { - id("com.tonapps.wallet.data") -} - -android { - namespace = Build.namespacePrefix("wallet.data.settings") + id("target.android.library") } dependencies { - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.rn)) - implementation(project(ProjectModules.Wallet.localization)) + implementation(libs.koin.core) + + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.rn) + implementation(projects.apps.wallet.localization) + implementation(projects.ui.uikit.core) } diff --git a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/ChartPeriod.kt b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/ChartPeriod.kt deleted file mode 100644 index d1932e3c0..000000000 --- a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/ChartPeriod.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.tonapps.wallet.data.settings - -enum class ChartPeriod(val value: String, val title: String) { - hour("1H", "H"), - day("1D", "D"), - week("7D", "W"), - month("1M", "M"), - halfYear("6M", "6M"), - year("1Y", "Y"); - - companion object { - - fun of(value: String?): ChartPeriod { - if (value == null) return month - return entries.firstOrNull { it.value == value } ?: month - } - } -} diff --git a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/RNMigrationHelper.kt b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/RNMigrationHelper.kt index 58c647240..b0c5783b3 100644 --- a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/RNMigrationHelper.kt +++ b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/RNMigrationHelper.kt @@ -4,7 +4,7 @@ import android.content.Context import com.tonapps.extensions.locale import com.tonapps.wallet.data.core.SearchEngine import com.tonapps.wallet.data.core.Theme -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.rn.RNLegacy import com.tonapps.wallet.data.rn.data.RNWallets import com.tonapps.wallet.localization.Language diff --git a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/SettingsRepository.kt b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/SettingsRepository.kt index da8726b73..d6bce88c8 100644 --- a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/SettingsRepository.kt +++ b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/SettingsRepository.kt @@ -6,22 +6,26 @@ import android.content.res.Configuration import android.icu.util.Currency import androidx.core.content.edit import androidx.core.os.LocaleListCompat +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.extensions.MutableEffectFlow import com.tonapps.extensions.clear import com.tonapps.extensions.locale import com.tonapps.extensions.putBoolean import com.tonapps.extensions.putInt import com.tonapps.extensions.putString +import com.tonapps.wallet.api.API import com.tonapps.wallet.data.core.SearchEngine import com.tonapps.wallet.data.core.Theme -import com.tonapps.wallet.data.core.currency.WalletCurrency import com.tonapps.wallet.data.core.isAvailableBiometric import com.tonapps.wallet.data.rn.RNLegacy import com.tonapps.wallet.data.settings.entities.PreferredFeeMethod +import com.tonapps.wallet.data.settings.entities.PreferredTronFeeMethod import com.tonapps.wallet.data.settings.entities.TokenPrefsEntity import com.tonapps.wallet.data.settings.folder.TokenPrefsFolder import com.tonapps.wallet.data.settings.folder.WalletPrefsFolder import com.tonapps.wallet.localization.Language +import uikit.chart.ChartPeriod import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -42,6 +46,7 @@ class SettingsRepository( private val scope: CoroutineScope, private val context: Context, private val rnLegacy: RNLegacy, + private val api: API, ) { private companion object { @@ -64,6 +69,7 @@ class SettingsRepository( private const val ADDRESS_COPY_COUNT_KEY = "address_copy_count" private const val STORIES_VIEWED_PREFIX = "stories_viewed_" private const val LEDGER_CONNECT_USB = "ledger_connect_usb" + private const val SEND_TOOLTIP_SHOWN_KEY = "send_tooltip_shown" } private val _currencyFlow = MutableEffectFlow() @@ -258,6 +264,14 @@ class SettingsRepository( } } + var sendTooltipShown: Boolean = prefs.getBoolean(SEND_TOOLTIP_SHOWN_KEY, false) + set(value) { + if (value != field) { + prefs.putBoolean(SEND_TOOLTIP_SHOWN_KEY, value) + field = value + } + } + var showSafeModeSetup: Boolean = prefs.getBoolean(SHOW_SAFE_MODE_SETUP_KEY, false) set(value) { if (value != field) { @@ -391,6 +405,15 @@ class SettingsRepository( walletPrefsFolder.setBatteryTxEnabled(accountId, types) } + fun isSafeModeEnabled(network: TonNetwork): Boolean { + val state = getSafeModeState() + if (state == SafeModeState.Default) { + return api.getConfig(network).flags.safeModeEnabled + } + + return state == SafeModeState.Enabled + } + fun batteryEnableTx( accountId: String, type: BatteryTransaction, @@ -478,6 +501,12 @@ class SettingsRepository( fun setPreferredFeeMethod(walletId: String, method: PreferredFeeMethod) = walletPrefsFolder.setPreferredFeeMethod(walletId, method) + fun getPreferredTronFeeMethod(walletId: String) = + walletPrefsFolder.getPreferredTronFeeMethod(walletId) + + fun setPreferredTronFeeMethod(walletId: String, method: PreferredTronFeeMethod) = + walletPrefsFolder.setPreferredTronFeeMethod(walletId, method) + suspend fun getTokenPrefs( walletId: String, tokenAddress: String, @@ -595,5 +624,4 @@ class SettingsRepository( code } } - -} \ No newline at end of file +} diff --git a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/entities/PreferredTronFeeMethod.kt b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/entities/PreferredTronFeeMethod.kt new file mode 100644 index 000000000..3f0ac61dc --- /dev/null +++ b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/entities/PreferredTronFeeMethod.kt @@ -0,0 +1,16 @@ +package com.tonapps.wallet.data.settings.entities + +enum class PreferredTronFeeMethod(val id: Int) { + UNSPECIFIED(0), + TRX(1), + TON(2), + BATTERY(3); + + companion object { + fun fromId(id: Int): PreferredTronFeeMethod { + return entries.find { it.id == id } + ?: throw IllegalArgumentException("Invalid PreferredTronFeeMethod id: $id") + } + } +} + diff --git a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/BaseSettingsFolder.kt b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/BaseSettingsFolder.kt index e87c75a8a..70eb87bb2 100644 --- a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/BaseSettingsFolder.kt +++ b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/BaseSettingsFolder.kt @@ -4,19 +4,16 @@ import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit import com.tonapps.extensions.MutableEffectFlow -import com.tonapps.extensions.getByteArray import com.tonapps.extensions.getIntArray import com.tonapps.extensions.putBoolean import com.tonapps.extensions.putInt import com.tonapps.extensions.putIntArray import com.tonapps.extensions.putLong -import com.tonapps.extensions.state import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.delayFlow import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn diff --git a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/TokenPrefsFolder.kt b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/TokenPrefsFolder.kt index f374c59d0..3cc62e4d8 100644 --- a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/TokenPrefsFolder.kt +++ b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/TokenPrefsFolder.kt @@ -42,7 +42,7 @@ internal class TokenPrefsFolder(context: Context, scope: CoroutineScope) : fun getHidden(walletId: String, tokenAddress: String): Boolean { // trc20 usdt - val defValue = tokenAddress == "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + val defValue = tokenAddress == "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" || tokenAddress == "TRX" return getBoolean(keyHidden(walletId, tokenAddress), defValue) } diff --git a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/WalletPrefsFolder.kt b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/WalletPrefsFolder.kt index 295fe4fb5..cf69fb41b 100644 --- a/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/WalletPrefsFolder.kt +++ b/apps/wallet/data/settings/src/main/java/com/tonapps/wallet/data/settings/folder/WalletPrefsFolder.kt @@ -2,12 +2,13 @@ package com.tonapps.wallet.data.settings.folder import android.content.Context import android.os.SystemClock -import android.util.Log +import com.tonapps.log.L import com.tonapps.wallet.data.settings.BatteryTransaction import com.tonapps.wallet.data.settings.BatteryTransaction.Companion.toIntArray import com.tonapps.wallet.data.settings.SettingsRepository import com.tonapps.wallet.data.settings.SpamTransactionState import com.tonapps.wallet.data.settings.entities.PreferredFeeMethod +import com.tonapps.wallet.data.settings.entities.PreferredTronFeeMethod import com.tonapps.wallet.data.settings.entities.WalletPrefsEntity import kotlinx.coroutines.CoroutineScope @@ -25,6 +26,7 @@ internal class WalletPrefsFolder(context: Context, scope: CoroutineScope): BaseS private const val USDT_W5_PREFIX = "usdt_w5_" private const val DAPP_CONFIRM_PREFIX = "dapp_confirm_" private const val PREFERRED_FEE_PREFIX = "preferred_fee_" + private const val PREFERRED_TRON_FEE_PREFIX = "preferred_tron_fee_" } fun isUSDTW5(walletId: String): Boolean { @@ -138,10 +140,24 @@ internal class WalletPrefsFolder(context: Context, scope: CoroutineScope): BaseS putInt(key, method.id) } + fun getPreferredTronFeeMethod(walletId: String): PreferredTronFeeMethod { + val value = getInt(keyPreferredTronFeeMethod(walletId), PreferredTronFeeMethod.UNSPECIFIED.id) + return PreferredTronFeeMethod.fromId(value) + } + + fun setPreferredTronFeeMethod(walletId: String, method: PreferredTronFeeMethod) { + val key = keyPreferredTronFeeMethod(walletId) + putInt(key, method.id) + } + private fun keyPreferredFeeMethod(walletId: String): String { return key(PREFERRED_FEE_PREFIX, walletId) } + private fun keyPreferredTronFeeMethod(walletId: String): String { + return key(PREFERRED_TRON_FEE_PREFIX, walletId) + } + private fun keyBatteryTxEnabled(accountId: String): String { return key(BATTERY_TX_ENABLED_PREFIX, accountId) } diff --git a/apps/wallet/data/staking/build.gradle.kts b/apps/wallet/data/staking/build.gradle.kts index c9dfa38b4..269ac3123 100644 --- a/apps/wallet/data/staking/build.gradle.kts +++ b/apps/wallet/data/staking/build.gradle.kts @@ -1,20 +1,17 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.staking") -} - dependencies { - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.icu)) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) + implementation(libs.koin.core) - implementation(project(ProjectModules.Module.tonApi)) + implementation(projects.tonapi.legacy) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.tokens)) + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.tokens) } diff --git a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingPool.kt b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingPool.kt index 8de75053a..ebafce0cf 100644 --- a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingPool.kt +++ b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingPool.kt @@ -1,5 +1,6 @@ package com.tonapps.wallet.data.staking +import com.tonapps.apps.wallet.data.staking.R import com.tonapps.icu.Coins import io.tonapi.models.PoolImplementationType diff --git a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingRepository.kt b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingRepository.kt index 86217deff..6837dc979 100644 --- a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingRepository.kt +++ b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/StakingRepository.kt @@ -1,10 +1,9 @@ package com.tonapps.wallet.data.staking import android.content.Context +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.staking.entities.PoolInfoEntity import com.tonapps.wallet.data.staking.entities.StakingEntity -import com.tonapps.wallet.data.staking.entities.StakingInfoEntity import com.tonapps.wallet.data.staking.source.LocalDataSource import com.tonapps.wallet.data.staking.source.RemoteDataSource import kotlinx.coroutines.Dispatchers @@ -17,24 +16,35 @@ class StakingRepository(context: Context, api: API) { suspend fun get( accountId: String, - testnet: Boolean, + network: TonNetwork, ignoreCache: Boolean = false, initializedAccount: Boolean = true ): StakingEntity = withContext(Dispatchers.IO) { - val cacheKey = cacheKey(accountId, testnet) - val local: StakingEntity? = if (ignoreCache) null else localDataSource.getCache(cacheKey) - if (local == null) { - val remote = remoteDataSource.load(accountId, testnet, initializedAccount) - localDataSource.setCache(cacheKey, remote) - return@withContext remote + val cacheKey = cacheKey(accountId, network) + + var pools = if (ignoreCache) null else localDataSource.getPools(cacheKey) + var info = if (ignoreCache) null else localDataSource.getInfo(cacheKey) + + val needsPools = pools == null + val needsInfo = initializedAccount && info == null + + if (needsPools || needsInfo) { + val remote = remoteDataSource.load(accountId, network, initializedAccount) + if (needsPools) { + pools = remote.pools + localDataSource.setPools(cacheKey, remote.pools) + } + + if (initializedAccount) { + info = remote.info + localDataSource.setInfo(cacheKey, remote.info) + } } - return@withContext local + + StakingEntity(pools = pools, info = info ?: emptyList()) } - private fun cacheKey(accountId: String, testnet: Boolean): String { - if (!testnet) { - return accountId - } - return "${accountId}_testnet_2" + private fun cacheKey(accountId: String, network: TonNetwork): String { + return "${accountId}_${network.name.lowercase()}_2" } -} \ No newline at end of file +} diff --git a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/entities/StakingInfoEntity.kt b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/entities/StakingInfoEntity.kt index d2049c608..da63408d9 100644 --- a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/entities/StakingInfoEntity.kt +++ b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/entities/StakingInfoEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.staking.entities import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import com.tonapps.icu.Coins import com.tonapps.wallet.data.staking.StakingPool import io.tonapi.models.AccountStakingInfo diff --git a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/LocalDataSource.kt b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/LocalDataSource.kt index 3c649273c..fe17fdf12 100644 --- a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/LocalDataSource.kt +++ b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/LocalDataSource.kt @@ -3,18 +3,33 @@ package com.tonapps.wallet.data.staking.source import android.content.Context import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toListParcel -import com.tonapps.extensions.toParcel import com.tonapps.wallet.data.core.BlobDataSource import com.tonapps.wallet.data.staking.entities.PoolInfoEntity -import com.tonapps.wallet.data.staking.entities.StakingEntity -import java.util.concurrent.TimeUnit +import com.tonapps.wallet.data.staking.entities.StakingInfoEntity -internal class LocalDataSource(context: Context): BlobDataSource( - context = context, - path = "staking" -) { +internal class LocalDataSource(context: Context) { - override fun onMarshall(data: StakingEntity) = data.toByteArray() + private val poolsCache = object : BlobDataSource>( + context = context, + path = "staking_pools" + ) { + override fun onMarshall(data: List) = data.toByteArray() + override fun onUnmarshall(bytes: ByteArray) = bytes.toListParcel() + } - override fun onUnmarshall(bytes: ByteArray) = bytes.toParcel() -} \ No newline at end of file + private val infoCache = object : BlobDataSource>( + context = context, + path = "staking_info" + ) { + override fun onMarshall(data: List) = data.toByteArray() + override fun onUnmarshall(bytes: ByteArray) = bytes.toListParcel() + } + + fun getPools(key: String): List? = poolsCache.getCache(key) + + fun setPools(key: String, value: List) = poolsCache.setCache(key, value) + + fun getInfo(key: String): List? = infoCache.getCache(key) + + fun setInfo(key: String, value: List) = infoCache.setCache(key, value) +} diff --git a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/RemoteDataSource.kt b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/RemoteDataSource.kt index a9686cdd5..e2f9613de 100644 --- a/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/RemoteDataSource.kt +++ b/apps/wallet/data/staking/src/main/java/com/tonapps/wallet/data/staking/source/RemoteDataSource.kt @@ -1,5 +1,6 @@ package com.tonapps.wallet.data.staking.source +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.api.API import com.tonapps.wallet.api.withRetry import com.tonapps.wallet.data.staking.StakingPool @@ -17,12 +18,12 @@ internal class RemoteDataSource( ) { suspend fun load( - accountId: String, testnet: Boolean, initializedAccount: Boolean + accountId: String, network: TonNetwork, initializedAccount: Boolean ): StakingEntity = withContext(Dispatchers.IO) { - val poolsDeferred = async { loadPools(accountId, testnet) } + val poolsDeferred = async { loadPools(accountId, network) } val infoDeferred = async { if (initializedAccount) { - loadInfo(accountId, testnet) + loadInfo(accountId, network) } else { emptyList() } @@ -38,19 +39,19 @@ internal class RemoteDataSource( } private fun loadInfo( - accountId: String, testnet: Boolean + accountId: String, network: TonNetwork ): List { val list = withRetry { - api.staking(testnet).getAccountNominatorsPools(accountId).pools + api.staking(network).getAccountNominatorsPools(accountId).pools } ?: return emptyList() return list.map { StakingInfoEntity(it) } } private fun loadPools( - accountId: String, testnet: Boolean + accountId: String, network: TonNetwork ): List { val response = withRetry { - api.staking(testnet).getStakingPools(accountId, includeUnverified = false) + api.staking(network).getStakingPools(accountId, includeUnverified = false) } ?: return emptyList() val maxApyImplementation = response.pools.maxByOrNull { it.apy }?.implementation diff --git a/apps/wallet/data/swap/build.gradle.kts b/apps/wallet/data/swap/build.gradle.kts index d7b4b01bb..488f081e4 100644 --- a/apps/wallet/data/swap/build.gradle.kts +++ b/apps/wallet/data/swap/build.gradle.kts @@ -1,17 +1,14 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.swap") -} - dependencies { implementation(libs.koin.core) - implementation(libs.kotlinX.coroutines.guava) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.icu)) + implementation(libs.kotlinx.coroutines.core) + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.core) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) } diff --git a/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/SwapRepository.kt b/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/SwapRepository.kt index 696682432..eadd540d3 100644 --- a/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/SwapRepository.kt +++ b/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/SwapRepository.kt @@ -7,7 +7,7 @@ import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toListParcel import com.tonapps.wallet.api.API import com.tonapps.wallet.data.core.BlobDataSource -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.swap.entity.SwapAssetEntity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -35,10 +35,16 @@ class SwapRepository( val assetsFlow = flow { emit(getAssets()) + emit(getAssets(true)) }.mapList { it.currency }.stateIn(scope, SharingStarted.Lazily, null).filterNotNull() - suspend fun getAssets(): List = withContext(Dispatchers.IO) { - getCache(ASSETS_KEY) ?: loadAssets() + suspend fun getAssets(ignoreCache: Boolean = false): List = withContext(Dispatchers.IO) { + val cached = if (ignoreCache) { + null + } else { + getCache(ASSETS_KEY) + } + cached ?: loadAssets() } private fun loadAssets(): List { diff --git a/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/entity/SwapAssetEntity.kt b/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/entity/SwapAssetEntity.kt index 6413bb5f6..94f098430 100644 --- a/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/entity/SwapAssetEntity.kt +++ b/apps/wallet/data/swap/src/main/java/com/tonapps/wallet/data/swap/entity/SwapAssetEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.swap.entity import android.os.Parcelable -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.json.JSONObject diff --git a/apps/wallet/data/tokens/build.gradle.kts b/apps/wallet/data/tokens/build.gradle.kts index d42770fb3..8c40a0c27 100644 --- a/apps/wallet/data/tokens/build.gradle.kts +++ b/apps/wallet/data/tokens/build.gradle.kts @@ -1,18 +1,16 @@ plugins { - id("com.tonapps.wallet.data") + id("target.android.library") id("kotlin-parcelize") } -android { - namespace = Build.namespacePrefix("wallet.data.tokens") -} - dependencies { - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.rates)) - implementation(project(ProjectModules.Wallet.api)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.icu)) + implementation(libs.koin.core) + + implementation(projects.lib.blockchain) + implementation(projects.tonapi.legacy) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.rates) + implementation(projects.apps.wallet.api) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) } diff --git a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/TokenRepository.kt b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/TokenRepository.kt index e8798803c..ffb854b2f 100644 --- a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/TokenRepository.kt +++ b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/TokenRepository.kt @@ -1,16 +1,18 @@ package com.tonapps.wallet.data.token import android.content.Context -import android.util.Log import androidx.collection.ArrayMap +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.extensions.CacheKey +import com.tonapps.extensions.TimedCacheMemory import com.tonapps.icu.Coins import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.BalanceEntity import com.tonapps.wallet.api.entity.EthenaEntity -import com.tonapps.wallet.api.entity.TokenEntity import com.tonapps.wallet.data.core.BlobDataSource -import com.tonapps.wallet.data.core.currency.WalletCurrency import com.tonapps.wallet.data.rates.RatesRepository import com.tonapps.wallet.data.rates.entity.RatesEntity import com.tonapps.wallet.data.token.entities.AccountTokenEntity @@ -18,19 +20,23 @@ import com.tonapps.wallet.data.token.entities.TokenRateEntity import com.tonapps.wallet.data.token.source.LocalDataSource import com.tonapps.wallet.data.token.source.RemoteDataSource import io.tonapi.models.TokenRates -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap class TokenRepository( - private val context: Context, - private val scope: CoroutineScope, - private val ratesRepository: RatesRepository, private val api: API + context: Context, + private val ratesRepository: RatesRepository, + private val api: API ) { + sealed interface Keys : CacheKey { + data object Tokens : Keys + } + + private val timedCache = TimedCacheMemory() + private val totalBalanceCache = ConcurrentHashMap(3, 1.0f, 2) private val localDataSource = LocalDataSource(context) @@ -38,142 +44,165 @@ class TokenRepository( private val ethenaCache = BlobDataSource.simple(context, "ethena") - fun getToken(accountId: String, testnet: Boolean): TokenEntity? { + fun getToken(accountId: String, network: TonNetwork): TokenEntity? { if (accountId.equals("TON", ignoreCase = true)) { return TokenEntity.TON } - return remoteDataSource.getJetton(accountId, testnet) + + return remoteDataSource.getJetton(accountId, network) } - suspend fun getTokens(testnet: Boolean, accountIds: List): List = - withContext(Dispatchers.IO) { - if (accountIds.isEmpty()) { - return@withContext emptyList() + suspend fun getTokens(network: TonNetwork, accountIds: List): List { + // TODO add LRU Cache for tokens +// timedCache.getOrLoad(Keys.Tokens, "${accountIds.size}") { + return withContext(Dispatchers.IO) { + if (accountIds.isEmpty()) { + return@withContext emptyList() + } + val deferredTokens = accountIds.map { accountId -> + async { getToken(accountId, network) } // TODO load with batch + } + + deferredTokens.mapNotNull { it.await() } } - val deferredTokens = accountIds.map { accountId -> - async { getToken(accountId, testnet) } - } - deferredTokens.mapNotNull { it.await() } - } +// } + } fun getToken(accountId: String): TokenEntity? { - return getToken(accountId, false) ?: getToken(accountId, true) + return getToken(accountId, TonNetwork.MAINNET) ?: getToken(accountId, TonNetwork.TESTNET) } - suspend fun getToken(accountId: String, testnet: Boolean, tokenAddress: String): TokenEntity? { + suspend fun getToken(accountId: String, network: TonNetwork, tokenAddress: String): TokenEntity? { if (accountId.equals("TON", ignoreCase = true)) { return TokenEntity.TON } - val token = get(WalletCurrency.USD, accountId, testnet)?.firstOrNull { token -> + val token = get(WalletCurrency.USD, accountId, network)?.firstOrNull { token -> token.balance.token.address.equalsAddress(tokenAddress) }?.balance?.token - return token ?: getToken(tokenAddress, testnet) + return token ?: getToken(tokenAddress, network) } suspend fun getTON( currency: WalletCurrency, accountId: String, - testnet: Boolean, + network: TonNetwork, refresh: Boolean = false, ): AccountTokenEntity? { - val tokens = get(currency, accountId, testnet, refresh) ?: return null + val tokens = get(currency, accountId, network, refresh) ?: return null return tokens.firstOrNull { it.isTon } } suspend fun getTonBalance( currency: WalletCurrency, accountId: String, - testnet: Boolean + network: TonNetwork ): Coins? { - val token = getTON(currency, accountId, testnet, false) ?: return null + val token = getTON(currency, accountId, network, false) ?: return null return token.balance.value } suspend fun refreshTron( - accountId: String, testnet: Boolean, tronAddress: String + accountId: String, network: TonNetwork, tronAddress: String ) { val tronUsdtBalance = remoteDataSource.loadTronUsdt(tronAddress) + val tronTrxBalance = remoteDataSource.loadTronTrx(tronAddress) - val cached = localDataSource.getCache(cacheKey(accountId, false)) ?: return + val cached = localDataSource.getCache(cacheKey(accountId, network)) ?: return val entities = cached.toMutableList() - val index = - entities.indexOfFirst { it.token.address.equalsAddress(TokenEntity.TRON_USDT.address) } - if (index != -1) { - entities[index] = tronUsdtBalance - } else if (!api.config.flags.disableTron) { - entities.add(tronUsdtBalance) + run { + val index = entities.indexOfFirst { + it.token.address.equalsAddress(TokenEntity.TRON_USDT.address) + } + + if (index != -1) { + entities[index] = tronUsdtBalance + } else if (!api.getConfig(network).flags.disableTron) { + entities.add(tronUsdtBalance) + } + } + + run { + val index = entities.indexOfFirst { + it.token.address.equalsAddress(TokenEntity.TRX.address) + } + + if (index != -1) { + entities[index] = tronTrxBalance + } else if (!api.getConfig(network).flags.disableTron) { + entities.add(tronTrxBalance) + } } - localDataSource.setCache(cacheKey(accountId, testnet), entities) + localDataSource.setCache(cacheKey(accountId, network), entities) } suspend fun get( currency: WalletCurrency, accountId: String, - testnet: Boolean, + network: TonNetwork, refresh: Boolean = false, tronAddress: String? = null, ): List? { if (refresh) { - return getRemote(currency, accountId, tronAddress, testnet) + return getRemote(currency, accountId, tronAddress, network) } - val tokens = getLocal(currency, accountId, testnet) + val tokens = getLocal(currency, accountId, network) if (tokens.isNotEmpty()) { return tokens } - return getRemote(currency, accountId, tronAddress, testnet) + return getRemote(currency, accountId, tronAddress, network) } suspend fun mustGet( currency: WalletCurrency, accountId: String, - testnet: Boolean, + network: TonNetwork, refresh: Boolean = false, ): List { - return get(currency, accountId, testnet, refresh) ?: emptyList() + return get(currency, accountId, network, refresh) ?: emptyList() } private suspend fun getRemote( - currency: WalletCurrency, accountId: String, tronAddress: String?, testnet: Boolean + currency: WalletCurrency, accountId: String, tronAddress: String?, network: TonNetwork ): List? = withContext(Dispatchers.IO) { - val balances = load(currency, accountId, tronAddress, testnet) ?: return@withContext null - if (testnet) { - return@withContext buildTokens( + val balances = load(currency, accountId, tronAddress, network) ?: return@withContext null + if (network.isTestnet) { + return@withContext buildTokens( currency = currency, balances = balances, fiatRates = RatesEntity.empty(currency), - testnet = true + network = network ) } - val fiatRates = ratesRepository.getRates(currency, balances.map { it.token.address }) + val fiatRates = ratesRepository.getRates(network, currency, balances.map { it.token.address }) buildTokens( - currency = currency, balances = balances, fiatRates = fiatRates, testnet = false + currency = currency, balances = balances, fiatRates = fiatRates, network = network ) } suspend fun getLocal( - currency: WalletCurrency, accountId: String, testnet: Boolean + currency: WalletCurrency, accountId: String, network: TonNetwork ): List = withContext(Dispatchers.IO) { - val balances = cache(accountId, testnet) ?: return@withContext emptyList() - if (testnet) { - return@withContext buildTokens( + val balances = cache(accountId, network) ?: return@withContext emptyList() + if (network.isTestnet) { + return@withContext buildTokens( currency = currency, balances = balances, fiatRates = RatesEntity.empty(currency), - testnet = true + network = network ) } - val fiatRates = ratesRepository.cache(currency, balances.map { it.token.address }) + val fiatRates = ratesRepository.cache(network, currency, balances.map { it.token.address }) if (fiatRates.isEmpty) { emptyList() } else { buildTokens( - currency = currency, balances = balances, fiatRates = fiatRates, testnet = false + currency = currency, balances = balances, fiatRates = fiatRates, network = network ) } } @@ -182,7 +211,7 @@ class TokenRepository( currency: WalletCurrency, balances: List, fiatRates: RatesEntity, - testnet: Boolean + network: TonNetwork ): List { val verified = mutableListOf() val unverified = mutableListOf() @@ -203,7 +232,7 @@ class TokenRepository( unverified.add(token) } } - if (testnet) { + if (network.isTestnet) { return sortTestnet(verified + unverified) } return sort(verified) + sort(unverified) @@ -232,34 +261,46 @@ class TokenRepository( } private fun cache( - accountId: String, testnet: Boolean + accountId: String, network: TonNetwork ): List? { - val key = cacheKey(accountId, testnet) + val key = cacheKey(accountId, network) return localDataSource.getCache(key) } - private fun updateRates(currency: WalletCurrency, tokens: List) { - ratesRepository.load(currency, tokens.toMutableList()) + private fun updateRates(network: TonNetwork, currency: WalletCurrency, tokens: List) { + ratesRepository.load(network, currency, tokens.toMutableList()) } private suspend fun load( - currency: WalletCurrency, accountId: String, tronAddress: String?, testnet: Boolean + currency: WalletCurrency, + accountId: String, + tronAddress: String?, + network: TonNetwork ): List? = withContext(Dispatchers.IO) { - val tonBalanceDeferred = async { remoteDataSource.loadTON(currency, accountId, testnet) } - val jettonsDeferred = async { remoteDataSource.loadJettons(currency, accountId, testnet) } + val tonBalanceDeferred = async { remoteDataSource.loadTON(currency, accountId, network) } + val jettonsDeferred = async { remoteDataSource.loadJettons(currency, accountId, network) } + val tronUsdtDeferred = async { - if (tronAddress != null && !testnet) { + if (tronAddress != null && network.isMainnet) { remoteDataSource.loadTronUsdt(tronAddress) } else { null } } - val tonBalance = tonBalanceDeferred.await() ?: return@withContext null + val tronTrxDeferred = async { + if (tronAddress != null && network.isMainnet) { + remoteDataSource.loadTronTrx(tronAddress) + } else { + null + } + } + val tonBalance = tonBalanceDeferred.await() ?: return@withContext null val jettons = jettonsDeferred.await()?.toMutableList() ?: mutableListOf() val tronUsdt = tronUsdtDeferred.await() + val tronTrx = tronTrxDeferred.await() val usdtIndex = jettons.indexOfFirst { it.token.address == TokenEntity.USDT.address @@ -269,14 +310,22 @@ class TokenRepository( it.token.address == TokenEntity.USDE.address } + val tsUsdeIndex = jettons.indexOfFirst { + it.token.address == TokenEntity.TS_USDE.address + } + val entities = mutableListOf() entities.add(tonBalance) - if (tronUsdt != null && (!api.config.flags.disableTron || tronUsdt.value.isPositive)) { + if (tronTrx != null && (!api.getConfig(network).flags.disableTron || tronTrx.value.isPositive)) { + entities.add(tronTrx) + } + + if (tronUsdt != null && (!api.getConfig(network).flags.disableTron || tronUsdt.value.isPositive)) { entities.add(tronUsdt) } - if (usdtIndex == -1 && !testnet) { + if (usdtIndex == -1 && !network.isTestnet) { entities.add( BalanceEntity( token = TokenEntity.USDT, @@ -293,7 +342,9 @@ class TokenRepository( ) } - if (usdeIndex == -1 && !testnet && !api.config.flags.disableUsde) { + val shouldAddUsde = usdeIndex == -1 && (!api.getConfig(network).flags.disableUsde || tsUsdeIndex != -1) + + if (shouldAddUsde && !network.isTestnet) { entities.add( BalanceEntity( token = TokenEntity.USDE, @@ -312,28 +363,26 @@ class TokenRepository( entities.addAll(jettons) - updateRates(currency, listOf(TokenEntity.TON.symbol)) - bindRates(currency, entities) - localDataSource.setCache(cacheKey(accountId, testnet), entities) - totalBalanceCache.remove(cacheKey(accountId, testnet)) + updateRates(network, currency, listOf(TokenEntity.TON.symbol)) + bindRates(network, currency, entities) + localDataSource.setCache(cacheKey(accountId, network), entities) + totalBalanceCache.remove(cacheKey(accountId, network)) entities.toList() } - private fun cacheKey(accountId: String, testnet: Boolean): String { - if (!testnet) { - return accountId - } - return "${accountId}_testnet" + + private fun cacheKey(accountId: String, network: TonNetwork): String { + return "${accountId}_${network.name.lowercase()}" } - private suspend fun bindRates(currency: WalletCurrency, list: List) { + private suspend fun bindRates(network: TonNetwork, currency: WalletCurrency, list: List) { val rates = ArrayMap() for (balance in list) { - balance.rates?.let { + (balance.rates as? TokenRates)?.let { rates[balance.token.address] = it } } - ratesRepository.insertRates(currency, rates) + ratesRepository.insertRates(network, currency, rates) } suspend fun getEthena(accountId: String, refresh: Boolean = false): EthenaEntity? { @@ -351,4 +400,4 @@ class TokenRepository( } data } -} \ No newline at end of file +} diff --git a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/AccountTokenEntity.kt b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/AccountTokenEntity.kt index c0de65871..8fffb7b2b 100644 --- a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/AccountTokenEntity.kt +++ b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/AccountTokenEntity.kt @@ -2,10 +2,11 @@ package com.tonapps.wallet.data.token.entities import android.net.Uri import android.os.Parcelable +import com.tonapps.blockchain.contract.Blockchain import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @@ -16,33 +17,6 @@ data class AccountTokenEntity( private var fiatRate: TokenRateEntity? = null, ): Parcelable { - companion object { - - val EMPTY = AccountTokenEntity( - balance = BalanceEntity( - token = TokenEntity.TON, - value = Coins.ZERO, - walletAddress = "", - initializedAccount = false, - isRequestMinting = false, - isTransferable = true, - ) - ) - - fun createEmpty(token: TokenEntity, walletAddress: String): AccountTokenEntity { - return AccountTokenEntity( - balance = BalanceEntity( - token = token, - value = Coins.ZERO, - walletAddress = walletAddress, - initializedAccount = false, - isRequestMinting = false, - isTransferable = true, - ) - ) - } - } - val imageUri: Uri get() = balance.token.imageUri @@ -61,6 +35,9 @@ data class AccountTokenEntity( val isTon: Boolean get() = address == TokenEntity.TON.address + val isTrx: Boolean + get() = address == TokenEntity.TRX.address + val isLiquid: Boolean get() = balance.token.isLiquid @@ -85,6 +62,9 @@ data class AccountTokenEntity( val fiat: Coins get() = fiatRate?.fiat ?: Coins.ZERO + val fiatCurrency: WalletCurrency + get() = fiatRate?.currency ?: WalletCurrency.TON + val rateNow: Coins get() = fiatRate?.rate ?: Coins.ZERO @@ -112,4 +92,30 @@ data class AccountTokenEntity( val blockchain: Blockchain get() = balance.token.blockchain + companion object { + + val EMPTY = AccountTokenEntity( + balance = BalanceEntity( + token = TokenEntity.TON, + value = Coins.ZERO, + walletAddress = "", + initializedAccount = false, + isRequestMinting = false, + isTransferable = true, + ) + ) + + fun createEmpty(token: TokenEntity, walletAddress: String): AccountTokenEntity { + return AccountTokenEntity( + balance = BalanceEntity( + token = token, + value = Coins.ZERO, + walletAddress = walletAddress, + initializedAccount = false, + isRequestMinting = false, + isTransferable = true, + ) + ) + } + } } \ No newline at end of file diff --git a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/TokenRateEntity.kt b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/TokenRateEntity.kt index 231d29074..ff5583778 100644 --- a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/TokenRateEntity.kt +++ b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/entities/TokenRateEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.wallet.data.token.entities import com.tonapps.icu.Coins -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency data class TokenRateEntity( val currency: WalletCurrency, diff --git a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/LocalDataSource.kt b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/LocalDataSource.kt index ec2171e2f..ce8e3a34f 100644 --- a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/LocalDataSource.kt +++ b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/LocalDataSource.kt @@ -4,7 +4,7 @@ import android.content.Context import com.tonapps.extensions.prefs import com.tonapps.extensions.toByteArray import com.tonapps.extensions.toListParcel -import com.tonapps.wallet.api.entity.BalanceEntity +import com.tonapps.blockchain.model.legacy.BalanceEntity import com.tonapps.wallet.data.core.BlobDataSource internal class LocalDataSource(context: Context): BlobDataSource>( diff --git a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/RemoteDataSource.kt b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/RemoteDataSource.kt index ca0f72f52..a028a3a28 100644 --- a/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/RemoteDataSource.kt +++ b/apps/wallet/data/tokens/src/main/java/com/tonapps/wallet/data/token/source/RemoteDataSource.kt @@ -1,10 +1,11 @@ package com.tonapps.wallet.data.token.source +import com.tonapps.blockchain.ton.TonNetwork import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -12,25 +13,25 @@ internal class RemoteDataSource( private val api: API ) { - fun getJetton(accountId: String, testnet: Boolean) = api.getJetton(accountId, testnet) + fun getJetton(accountId: String, network: TonNetwork) = api.getJetton(accountId, network) suspend fun loadTON( currency: WalletCurrency, accountId: String, - testnet: Boolean + network: TonNetwork ): BalanceEntity? = withContext(Dispatchers.IO) { - api.getTonBalance(accountId, testnet, currency.code) + api.getTonBalance(accountId, network, currency.code) } suspend fun loadJettons( currency: WalletCurrency, accountId: String, - testnet: Boolean + network: TonNetwork ): List? = withContext(Dispatchers.IO) { try { api.getJettonsBalances( accountId = accountId, - testnet = testnet, + network = network, currency = currency.code, extensions = listOf( TokenEntity.Extension.CustomPayload.value, @@ -49,4 +50,10 @@ internal class RemoteDataSource( api.tron.getTronUsdtBalance(tronAddress) } + suspend fun loadTronTrx( + tronAddress: String, + ): BalanceEntity = withContext(Dispatchers.IO) { + api.tron.getTrxBalance(tronAddress) + } + } \ No newline at end of file diff --git a/apps/wallet/data/tx/build.gradle.kts b/apps/wallet/data/tx/build.gradle.kts new file mode 100644 index 000000000..a36e363d2 --- /dev/null +++ b/apps/wallet/data/tx/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("target.android.library") + id("kotlin-parcelize") +} + +dependencies { + implementation(libs.koin.core) + + implementation(projects.lib.blockchain) + implementation(projects.lib.network) + implementation(projects.tonapi.legacy) + + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.rates) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.battery) + implementation(projects.apps.wallet.data.tokens) + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.features) + implementation(projects.lib.features) + + implementation(projects.kmp.async) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) +} diff --git a/apps/wallet/data/tx/src/main/AndroidManifest.xml b/apps/wallet/data/tx/src/main/AndroidManifest.xml new file mode 100644 index 000000000..227314eeb --- /dev/null +++ b/apps/wallet/data/tx/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/BaseTransactionManager.kt b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/BaseTransactionManager.kt new file mode 100644 index 000000000..50bd925e5 --- /dev/null +++ b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/BaseTransactionManager.kt @@ -0,0 +1,50 @@ +package com.tonapps.wallet.data.tx + +import com.tonapps.async.Async +import com.tonapps.extensions.MutableEffectFlow +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.wallet.data.tx.model.PendingWrapEvent +import com.tonapps.wallet.api.API +import com.tonapps.wallet.api.withRetry +import com.tonapps.wallet.data.tx.model.PendingHash +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withContext + +open class BaseTransactionManager( + private val api: API +) { + + val scope = Async.ioScope() + + private val _pendingHashFlow = MutableEffectFlow() + private val pendingTxFlow = _pendingHashFlow.mapNotNull(::fetchPendingTx) + .flowOn(Dispatchers.IO) + + + fun addPendingHash(accountId: String, network: TonNetwork, hash: String) { + _pendingHashFlow.tryEmit(PendingHash(accountId, network, hash)) + } + + private suspend fun fetchTx( + accountId: String, + network: TonNetwork, + hash: String + ) = withContext(Dispatchers.IO) { + withRetry { + api.accounts(network).getAccountEvent(accountId, hash) + } + } + + private suspend fun fetchPendingTx(hash: PendingHash): PendingWrapEvent? { + val event = fetchTx( + accountId = hash.accountId, + network = hash.network, + hash = hash.hash + ) ?: return null + return PendingWrapEvent(hash, event) + } + + +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/SendingTransaction.kt b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/SendingTransaction.kt similarity index 79% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/SendingTransaction.kt rename to apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/SendingTransaction.kt index 59dc0fc5c..6f148122e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/SendingTransaction.kt +++ b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/SendingTransaction.kt @@ -1,7 +1,7 @@ -package com.tonapps.tonkeeper.manager.tx +package com.tonapps.wallet.data.tx import com.tonapps.blockchain.ton.extensions.cellFromBase64 -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.ton.cell.Cell data class SendingTransaction( @@ -16,4 +16,4 @@ data class SendingTransaction( wallet = wallet, boc = boc.cellFromBase64() ) -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/TransactionEvent.kt b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/TransactionEvent.kt similarity index 88% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/TransactionEvent.kt rename to apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/TransactionEvent.kt index 459b11098..b6c18395f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/TransactionEvent.kt +++ b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/TransactionEvent.kt @@ -1,4 +1,4 @@ -package com.tonapps.tonkeeper.manager.tx +package com.tonapps.wallet.data.tx import org.json.JSONObject diff --git a/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/TransactionManager.kt b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/TransactionManager.kt new file mode 100644 index 000000000..2d515c3a5 --- /dev/null +++ b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/TransactionManager.kt @@ -0,0 +1,237 @@ +package com.tonapps.wallet.data.tx + +import com.tonapps.blockchain.ton.extensions.base64 +import com.tonapps.core.flags.WalletFeature +import com.tonapps.extensions.MutableEffectFlow +import com.tonapps.log.L +import com.tonapps.wallet.api.API +import com.tonapps.wallet.api.entity.AccountEventEntity +import com.tonapps.wallet.api.entity.ConfigEntity +import com.tonapps.wallet.api.entity.toncenter.ToncenterEvent +import com.tonapps.wallet.api.entity.toncenter.TransactionsEvent +import io.tonapi.models.AccountAddress +import io.tonapi.models.AccountEvent +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.errors.SendBlockchainException +import com.tonapps.wallet.data.battery.BatteryRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.token.TokenRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.ton.cell.Cell +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class TransactionManager( + private val accountRepository: AccountRepository, + private val api: API, + private val batteryRepository: BatteryRepository, + private val tokenRepository: TokenRepository, + private val settingsRepository: SettingsRepository, + private val delegate: Delegate, +): BaseTransactionManager(api) { + + interface Delegate { + fun onUpdateWidget() + } + + private val _sendingTransactionFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val sendingTransactionFlow = _sendingTransactionFlow.asSharedFlow() + + private val _transactionFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val transactionFlow = _transactionFlow.asSharedFlow() + + // TODO + private val _tronUpdatedFlow = MutableEffectFlow() + val tronUpdatedFlow = _tronUpdatedFlow.asSharedFlow() + + private var tronRefreshJob: Job? = null + + init { + _tronUpdatedFlow.tryEmit(Unit) + sendingTransactionFlow.mapNotNull { getTransaction(it.wallet, it.hash) } + .onEach { transaction -> + _transactionFlow.tryEmit(transaction) + } + .launchIn(scope) + + combine( + api.configFlow.filter { !it.empty }, + accountRepository.selectedWalletFlow + ) { config, wallet -> + realtime(config, wallet) + }.flatMapLatest { it } + .filterNotNull() + .onEach { transaction -> _transactionFlow.tryEmit(transaction) } + .launchIn(scope) + + // TODO refactor + sendingTransactionFlow.onEach { + delay(5000) // TODO WTF, maybe debounce? + delegate.onUpdateWidget() + }.launchIn(scope) + + combine( + accountRepository.selectedWalletFlow, + tronUpdatedFlow, + settingsRepository.tokenPrefsChangedFlow + ) { wallet, _, _ -> + val tronEnabled = settingsRepository.getTronUsdtEnabled(wallet.id) + val tronAddress = accountRepository.getTronAddress(wallet.id) + if (tronEnabled && tronAddress != null && wallet.hasPrivateKey && !wallet.testnet && !api.getConfig(wallet.network).flags.disableBattery) { + Pair(wallet, tronAddress) + } else { + null + } + }.filterNotNull().onEach { (wallet, tronAddress) -> + tronRefreshJob?.cancel() + tronRefreshJob = scope.launch { + delay(60.seconds) + tokenRepository.refreshTron(wallet.accountId, wallet.network, tronAddress) + + _tronUpdatedFlow.tryEmit(Unit) + } + }.launchIn(scope) + } + + fun eventsFlow(wallet: WalletEntity) = transactionFlow.filter { + it.accountId == wallet.accountId && it.testnet == wallet.testnet + } + + private fun realtime(config: ConfigEntity, wallet: WalletEntity): Flow { + return if (WalletFeature.StreamingV2.isEnabled) { + realtimeV2(config, wallet) + } else { + realtimeV1(config, wallet) + } + } + + private fun realtimeV1(config: ConfigEntity, wallet: WalletEntity) = api.realtime( + accountId = wallet.accountId, + network = wallet.network, + config = config, + onFailure = null + ).map { it.data }.map { getTransaction(wallet, it) } + + private fun realtimeV2(config: ConfigEntity, wallet: WalletEntity) = api.realtimeV2( + accountId = wallet.accountId, + network = wallet.network, + config = config, + onFailure = null + ).mapNotNull { + L.d("realtimeV2", it.json) + ToncenterEvent.parse(it.json) + }.filterIsInstance() + .mapNotNull { event -> + parseTransactionsEvent(event, wallet) + } + + private fun parseTransactionsEvent( + event: TransactionsEvent, + wallet: WalletEntity, + ): AccountEventEntity? { + val txArray = event.transactions ?: return null + if (txArray.length() == 0) return null + val firstTx = txArray.getJSONObject(0) + val hash = event.traceExternalHashNorm ?: firstTx.optString("hash", "") + val lt = firstTx.optString("lt", "0")?.toLongOrNull() ?: 0L + val timestamp = firstTx.optLong("now", 0L) + val inProgress = event.finality != "finalized" + + val body = AccountEvent( + eventId = hash, + account = AccountAddress(address = wallet.accountId, isScam = false, isWallet = true), + timestamp = timestamp, + actions = emptyList(), + isScam = false, + lt = lt, + inProgress = inProgress, + extra = 0, + progress = if (inProgress) 0f else 1f, + ) + return AccountEventEntity(wallet.accountId, wallet.testnet, hash, body) + } + + private suspend fun getTransaction( + wallet: WalletEntity, + hash: String + ): AccountEventEntity? = withContext(Dispatchers.IO) { + api.getTransactionByHash(wallet.accountId, wallet.network, hash) + } + + private suspend fun sendWithBattery( + wallet: WalletEntity, + boc: String, + source: String, + confirmationTime: Double, + ) { + val tonProofToken = accountRepository.requestTonProofToken(wallet) + ?: throw SendBlockchainException.SendBlockchainUnknownException + api.sendToBlockchainWithBattery( + boc = boc, + tonProofToken = tonProofToken, + network = wallet.network, + source = source, + confirmationTime = confirmationTime + ) + batteryRepository.refreshBalanceDelay( + publicKey = wallet.publicKey, + tonProofToken = tonProofToken, + network = wallet.network, + ) + } + + suspend fun send( + wallet: WalletEntity, + boc: String, + withBattery: Boolean, + source: String, + confirmationTime: Double, + ) { + if (withBattery) { + sendWithBattery(wallet, boc, source, confirmationTime) + } else { + api.sendToBlockchain(boc, wallet.network, source, confirmationTime) + } + _sendingTransactionFlow.tryEmit(SendingTransaction(wallet.copy(), boc)) + } + + suspend fun send( + wallet: WalletEntity, + boc: Cell, + withBattery: Boolean, + source: String, + confirmationTime: Double, + ) = send( + wallet = wallet, + boc = boc.base64(), + withBattery = withBattery, + source = source, + confirmationTime = confirmationTime + ) +} \ No newline at end of file diff --git a/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/model/PendingHash.kt b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/model/PendingHash.kt new file mode 100644 index 000000000..ef4b9bebc --- /dev/null +++ b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/model/PendingHash.kt @@ -0,0 +1,9 @@ +package com.tonapps.wallet.data.tx.model + +import com.tonapps.blockchain.ton.TonNetwork + +data class PendingHash( + val accountId: String, + val network: TonNetwork, + val hash: String +) \ No newline at end of file diff --git a/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/model/PendingWrapEvent.kt b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/model/PendingWrapEvent.kt new file mode 100644 index 000000000..934574dcb --- /dev/null +++ b/apps/wallet/data/tx/src/main/kotlin/com/tonapps/wallet/data/tx/model/PendingWrapEvent.kt @@ -0,0 +1,8 @@ +package com.tonapps.wallet.data.tx.model + +import io.tonapi.models.AccountEvent + +data class PendingWrapEvent( + val hash: PendingHash, + val event: AccountEvent +) \ No newline at end of file diff --git a/apps/wallet/features/core/build.gradle.kts b/apps/wallet/features/core/build.gradle.kts new file mode 100644 index 000000000..fcda9a7d7 --- /dev/null +++ b/apps/wallet/features/core/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + id("target.android.compose") + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.compose.debugTooling) + + implementation(platform(libs.firebase.bom)) + + implementation(libs.bundles.nav3) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.core) + + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.rn) + + implementation(projects.apps.wallet.localization) + + implementation(projects.kmp.core) + implementation(projects.kmp.ui) + implementation(projects.kmp.mvi) + implementation(projects.kmp.async) + + implementation(projects.ui.uikit.icon) + implementation(projects.ui.uikit.core) + + implementation(projects.lib.icu) + implementation(projects.lib.bus) + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) + implementation(projects.lib.features) + implementation(projects.apps.wallet.data.features) +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/AccountRepositoryExt.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/AccountRepositoryExt.kt new file mode 100644 index 000000000..7b04fe631 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/AccountRepositoryExt.kt @@ -0,0 +1,57 @@ +package com.tonapps.core + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.rn.RNException +import com.tonapps.wallet.data.rn.RNLegacy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.ton.api.pk.PrivateKeyEd25519 +import org.ton.mnemonic.Mnemonic +import uikit.navigation.NavigationActivity + +suspend fun AccountRepository.sign( + activity: NavigationActivity, + rnLegacy: RNLegacy, + walletId: String, + bytes: ByteArray, +): ByteArray { + var privateKey: PrivateKeyEd25519? = requestPrivateKey(activity, rnLegacy, walletId) + val signature = privateKey?.sign(bytes) + ?: throw IllegalStateException("Signature is null") // SendException.UnableSendTransaction() + privateKey = null +// TODO tryCallGC() + return signature +} + +suspend fun AccountRepository.requestPrivateKey( + activity: NavigationActivity, + rnLegacy: RNLegacy, + walletId: String, +): PrivateKeyEd25519? = withContext(Dispatchers.IO) { + try { + val privateKeyEd25519 = getPrivateKey(walletId) + if (privateKeyEd25519 != null) { + privateKeyEd25519 + } else { + val vaultState = rnLegacy.requestVault(activity) + val mnemonic = vaultState.getDecryptedData(walletId)?.mnemonic + ?: throw RNException.NotFoundMnemonic(walletId) + val seed = Mnemonic.toSeed(splitMnemonic(mnemonic)) + PrivateKeyEd25519(seed) + } + } catch (e: Throwable) { + FirebaseCrashlytics.getInstance().recordException(e) + null + } +} + +private fun splitMnemonic(mnemonic: String): List { + val words = if (mnemonic.contains(",")) { + mnemonic.split(",") + } else { + mnemonic.split(" ") + } + return words.map { it.trim() } +} + diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/ComposableFragment.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/ComposableFragment.kt new file mode 100644 index 000000000..326bf95da --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/ComposableFragment.kt @@ -0,0 +1,109 @@ +package com.tonapps.core + +import android.content.Context +import android.content.res.Configuration +import android.os.Bundle +import android.view.View +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner +import com.tonapps.apps.wallet.features.core.R +import com.tonapps.wallet.data.settings.SettingsRepository +import org.koin.android.ext.android.inject +import ui.theme.AppColorScheme +import ui.theme.MoonTheme +import ui.theme.appColorSchemeBlue +import ui.theme.appColorSchemeDark +import ui.theme.appColorSchemeLight +import uikit.base.BaseFragment + +abstract class ComposableFragment : BaseFragment(R.layout.fragment_compose_host) { + + val settings: SettingsRepository by inject() + + private val Context.uiMode: Int + get() = resources.configuration.uiMode + + private val Context.isDarkMode: Boolean + get() = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + + @get:Composable + private val theme: AppColorScheme + get() { + return when(settings.theme.key) { + "blue" -> appColorSchemeBlue() + "dark" -> appColorSchemeDark() + "light" -> appColorSchemeLight() + else -> if (requireContext().isDarkMode) appColorSchemeBlue() else appColorSchemeLight() + } + } + + private var isTopFragment by mutableStateOf(true) + + private var fragmentLifecycleCallbacksHost: FragmentManager? = null + + private val fragmentLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() { + override fun onFragmentAttached(fm: FragmentManager, f: Fragment, context: Context) { + updateTopFragmentState() + } + + override fun onFragmentResumed(fm: FragmentManager, f: Fragment) { + updateTopFragmentState() + } + + override fun onFragmentPaused(fm: FragmentManager, f: Fragment) { + updateTopFragmentState() + } + + override fun onFragmentDetached(fm: FragmentManager, f: Fragment) { + updateTopFragmentState() + } + } + + private fun updateTopFragmentState() { + if (!isAdded) return + isTopFragment = parentFragmentManager.fragments.lastOrNull() == this + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val fm = parentFragmentManager + fm.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) + fragmentLifecycleCallbacksHost = fm + updateTopFragmentState() + } + + override fun onDestroyView() { + fragmentLifecycleCallbacksHost?.unregisterFragmentLifecycleCallbacks(fragmentLifecycleCallbacks) + fragmentLifecycleCallbacksHost = null + super.onDestroyView() + } + + fun setContent(content: @Composable () -> Unit) { + view?.findViewById(R.id.compose_view)?.setContent { + MoonTheme(colorScheme = theme) { + val parent = LocalNavigationEventDispatcherOwner.current + if (parent == null) { + content() + } else { + val localDispatcherOwner = rememberNavigationEventDispatcherOwner( + enabled = isTopFragment, + parent = parent, + ) + CompositionLocalProvider( + LocalNavigationEventDispatcherOwner provides localDispatcherOwner, + ) { + content() + } + } + } + } + } +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/RNLegacy.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/RNLegacy.kt new file mode 100644 index 000000000..1be6ba98b --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/RNLegacy.kt @@ -0,0 +1,45 @@ +package com.tonapps.core + +import com.tonapps.wallet.data.rn.RNException +import com.tonapps.wallet.data.rn.RNLegacy +import com.tonapps.wallet.data.rn.data.RNVaultState +import com.tonapps.wallet.data.rn.data.RNWallets +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import uikit.navigation.NavigationActivity + +interface RNLegacyDelegate { + suspend fun onRequestPasscode(): String? +} + +suspend fun RNLegacy.requestVault( + activity: NavigationActivity, +): RNVaultState = withContext(Dispatchers.IO) { + val wallets = getWallets() + if (wallets.count == 0) { + throw IllegalStateException("No wallets found") + } + val passcode = requestPasscode(activity, wallets) + getVaultState(passcode) +} + +private suspend fun RNLegacy.requestPasscode( + activity: NavigationActivity, + wallets: RNWallets, +): String = withContext(Dispatchers.Main) { + val passcodeFromBiometry = if (wallets.biometryEnabled) { + exportPasscodeWithBiometry() + } else { + null + } + val passcode = if (!passcodeFromBiometry.isNullOrBlank()) { + passcodeFromBiometry + } else { + when { + activity is RNLegacyDelegate -> activity.onRequestPasscode() + else -> throw IllegalStateException("Activity can't provide passcode") + } + } + + passcode ?: throw RNException.NotFoundPasscode +} \ No newline at end of file diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/components/BannersViewer.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/components/BannersViewer.kt new file mode 100644 index 000000000..eb23f0750 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/components/BannersViewer.kt @@ -0,0 +1,403 @@ +package com.tonapps.core.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.api.entity.BannerEntity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import ui.components.moon.MoonAsyncImage +import ui.painterResource +import ui.theme.UIKit +import ui.theme.modifiers.modifyIf +import kotlin.math.abs + +private val BannerHeight = 90.dp +private val BannerBackPeek = 16.dp +private val BannerBottomMargin = 16.dp +private val BannerSideMargin = 16.dp +private val HideBlurRadius = 16.dp +private const val BannerBackScale = 0.88f +private const val BannerBackAlpha = 0.72f +private const val DismissThreshold = 0.3f +private const val SlideDurationMs = 300 +private const val ReturnDurationMs = 200 +private const val EntranceDurationMs = 150 + +@Composable +fun BannersViewer( + banners: List, + onHide: (BannerEntity) -> Unit, + onClick: (BannerEntity.Button) -> Unit, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val state = remember(banners) { BannerCarouselState(banners, scope) } + val widthPx = LocalWindowInfo.current.containerSize.width.toFloat() + val peekHeight by animateDpAsState( + targetValue = if (state.size > 1) BannerBackPeek else 0.dp, + label = "bannerPeekHeight", + ) + val collapseFraction = if (state.collapsing) state.fade.value else 0f + val containerHeight = if (state.items.isEmpty()) { + 0.dp + } else { + (BannerHeight + peekHeight + BannerBottomMargin) * (1f - collapseFraction) + } + + Box(modifier = modifier.height(containerHeight)) { + val current = state.current ?: return@Box + + state.next?.let { next -> + BackBanner( + banner = next, + promote = state.promote, + onClick = onClick, + ) + } + + FrontBanner( + banner = current, + offsetX = state.offsetX, + fade = state.fade.value, + draggable = state.size > 1 && !state.isAnimating, + onClick = onClick, + onHide = { state.hide(onHide) }, + onDrag = { delta -> state.drag(delta, widthPx) }, + onDragStopped = { state.dragStopped(widthPx) }, + ) + } +} + +@Stable +private class BannerCarouselState( + banners: List, + private val scope: CoroutineScope, +) { + var items by mutableStateOf(banners) + private set + private var index by mutableIntStateOf(0) + + var offsetX by mutableFloatStateOf(0f) + var promote by mutableFloatStateOf(0f) + val fade = Animatable(0f) + + var isAnimating by mutableStateOf(false) + private set + var collapsing by mutableStateOf(false) + private set + + val size: Int get() = items.size + + val current: BannerEntity? + get() = if (items.isEmpty()) null else items[index % items.size] + + val next: BannerEntity? + get() = if (items.size > 1) items[(index + 1) % items.size] else null + + fun drag(delta: Float, widthPx: Float) { + offsetX += delta + promote = (abs(offsetX) / widthPx).coerceIn(0f, 1f) + } + + fun dragStopped(widthPx: Float) { + if (abs(offsetX) > widthPx * DismissThreshold) { + advance(if (offsetX > 0) 1f else -1f, widthPx) + } else { + scope.launch { + launch { animateOffset(0f, ReturnDurationMs) } + animatePromote(0f, ReturnDurationMs) + } + } + } + + private fun advance(direction: Float, widthPx: Float) { + if (isAnimating || size <= 1) { + return + } + isAnimating = true + scope.launch { + val slide = launch { animateOffset(direction * widthPx, SlideDurationMs) } + val rise = launch { animatePromote(1f, SlideDurationMs) } + slide.join() + rise.join() + + index = (index + 1) % size + offsetX = 0f + animatePromote(0f, EntranceDurationMs) + isAnimating = false + } + } + + private suspend fun animateOffset(target: Float, durationMs: Int) { + val start = offsetX + animate( + initialValue = start, + targetValue = target, + animationSpec = tween(durationMs), + ) { value, _ -> + offsetX = value + } + } + + private suspend fun animatePromote(target: Float, durationMs: Int) { + val start = promote + animate( + initialValue = start, + targetValue = target, + animationSpec = tween(durationMs), + ) { value, _ -> + promote = value + } + } + + fun hide(onHide: (BannerEntity) -> Unit) { + if (isAnimating) { + return + } + val hidden = current ?: return + isAnimating = true + collapsing = next == null + scope.launch { + val fadeOut = launch { fade.animateTo(1f, tween(SlideDurationMs)) } + val rise = launch { if (size > 1) animatePromote(1f, SlideDurationMs) } + fadeOut.join() + rise.join() + + onHide(hidden) + items = items.filterNot { it.id == hidden.id } + fade.snapTo(0f) + offsetX = 0f + collapsing = false + animatePromote(0f, EntranceDurationMs) + isAnimating = false + } + } +} + +@Composable +private fun BoxScope.BackBanner( + banner: BannerEntity, + promote: Float, + onClick: (BannerEntity.Button) -> Unit, +) { + val scale = lerp(BannerBackScale, 1f, promote) + BannerCard( + banner = banner, + overlayAlpha = 1f - promote, + onClick = onClick, + onHide = {}, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(horizontal = BannerSideMargin) + .graphicsLayer { + scaleX = scale + scaleY = scale + alpha = lerp(BannerBackAlpha, 1f, promote) + translationY = lerp(BannerBackPeek.toPx(), 0f, promote) + }, + ) +} + +@Composable +private fun BoxScope.FrontBanner( + banner: BannerEntity, + offsetX: Float, + fade: Float, + draggable: Boolean, + onClick: (BannerEntity.Button) -> Unit, + onHide: () -> Unit, + onDrag: (Float) -> Unit, + onDragStopped: () -> Unit, +) { + BannerCard( + banner = banner, + onClick = onClick, + onHide = onHide, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(horizontal = BannerSideMargin) + .graphicsLayer { + translationX = offsetX + alpha = 1f - fade + } + .blur(HideBlurRadius * fade) + .draggable( + enabled = draggable, + orientation = Orientation.Horizontal, + state = rememberDraggableState { onDrag(it) }, + onDragStopped = { onDragStopped() }, + ), + ) +} + +@Composable +private fun BannerCard( + banner: BannerEntity, + onClick: (BannerEntity.Button) -> Unit, + onHide: () -> Unit, + modifier: Modifier = Modifier, + overlayAlpha: Float = 0f, +) { + val foregroundColor = UIKit.colorScheme.text.primary + val shape = UIKit.shapes.extraLarge + Box( + modifier = modifier + .height(BannerHeight) + .clip(shape) + .background(UIKit.colorScheme.background.pageAlternate) + .border( + width = 1.dp, + color = UIKit.colorScheme.buttonTertiary.primaryBackground, + shape = shape, + ) + .modifyIf { + banner.button?.let { button -> + noRippleClickable { onClick(button) } + } + } + ) { + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + .padding(start = 16.dp, end = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = banner.title, + style = UIKit.typography.label2, + color = foregroundColor, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + banner.button?.let { button -> + Row( + modifier = Modifier.alpha(0.64f), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = button.title, + style = UIKit.typography.body3, + color = foregroundColor, + ) + Icon( + modifier = Modifier + .padding(start = 2.dp) + .size(12.dp), + painter = painterResource(UIKitIcon.ic_chevron_right_12), + contentDescription = null, + tint = UIKit.colorScheme.text.primary, + ) + } + } + } + + banner.image?.let { image -> + MoonAsyncImage( + image = image, + modifier = Modifier + .width(134.dp) + .height(BannerHeight), + contentScale = ContentScale.Crop, + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .size(width = 54.dp, height = 44.dp) + .noRippleClickable(onHide) + .padding(10.dp), + contentAlignment = Alignment.TopEnd, + ) { + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(UIKit.colorScheme.background.contentAlternate), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(UIKitIcon.ic_close_small_16), + contentDescription = null, + tint = UIKit.colorScheme.icon.secondary, + ) + } + } + + val overlayColor = when { + UIKit.isLightTheme -> UIKit.colorScheme.background.pageAlternate + else -> UIKit.colorScheme.background.content + } + + if (overlayAlpha > 0f) { + Box( + modifier = Modifier + .matchParentSize() + .background(overlayColor.copy(alpha = overlayAlpha)) + ) + } + } +} + +@Composable +private fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier { + val interactionSource = remember { MutableInteractionSource() } + return clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLink.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLink.kt new file mode 100644 index 000000000..cf8c82da5 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLink.kt @@ -0,0 +1,64 @@ +package com.tonapps.core.deeplink + +import android.net.Uri +import androidx.core.net.toUri +import com.tonapps.bus.generated.Events + +data class DeepLink( + val route: DeepLinkRoute, + val fromQR: Boolean, + val referrer: Uri?, + val isTonConnect: Boolean = false, +) { + + enum class Source { + Deeplink, QR, TonConnect; // TODO refactor + + val analytic: Events.SendNative.SendNativeFrom get() { + return when (this) { + Deeplink -> Events.SendNative.SendNativeFrom.DeepLink + QR -> Events.SendNative.SendNativeFrom.QrCode + TonConnect -> Events.SendNative.SendNativeFrom.TonconnectLocal + } + } + } + + val source: Source get() { + return when { + isTonConnect -> Source.TonConnect + fromQR -> Source.QR + else -> Source.Deeplink + } + } + + companion object { + + fun fixBadUri(uri: Uri): Uri { + return fixBadUrl(uri.toString()).toUri() + } + + fun fixBadUrl(url: String): String { + var fixedUrl = url.replace("tg:resolve", "tg://resolve") + fixedUrl = fixedUrl.replace("\\u0026", "&") + return fixedUrl + } + + // TODO duplicated + fun isTonConnectDeepLink( + uri: Uri + ): Boolean { + return uri.scheme?.lowercase() == "tc" || uri.path?.lowercase() == "/ton-connect" || uri.host?.lowercase() == "ton-connect" + } + } + + constructor( + uri: Uri, + fromQR: Boolean, + referrer: Uri? + ): this( + route = DeepLinkRoute.resolve(uri), + fromQR = fromQR, + referrer = referrer, + isTonConnect = isTonConnectDeepLink(uri) + ) +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLinkBuilder.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLinkBuilder.kt similarity index 95% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLinkBuilder.kt rename to apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLinkBuilder.kt index 0f0f2e969..2b182c5a6 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLinkBuilder.kt +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLinkBuilder.kt @@ -1,4 +1,4 @@ -package com.tonapps.tonkeeper.deeplink +package com.tonapps.core.deeplink import android.net.Uri import com.tonapps.extensions.toUriOrNull diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLinkRoute.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLinkRoute.kt similarity index 84% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLinkRoute.kt rename to apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLinkRoute.kt index 0bb3ca0ec..0e15a6022 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLinkRoute.kt +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/deeplink/DeepLinkRoute.kt @@ -1,13 +1,11 @@ -package com.tonapps.tonkeeper.deeplink +package com.tonapps.core.deeplink import android.net.Uri -import android.util.Log import androidx.core.net.toUri import com.tonapps.blockchain.ton.extensions.cellFromBase64 import com.tonapps.blockchain.ton.extensions.isValidTonAddress import com.tonapps.blockchain.ton.extensions.isValidTonDomain import com.tonapps.blockchain.ton.extensions.publicKeyFromHex -import com.tonapps.blockchain.ton.extensions.toRawAddress import com.tonapps.blockchain.tron.isValidTronAddress import com.tonapps.extensions.currentTimeSeconds import com.tonapps.extensions.hasUnsupportedQuery @@ -16,13 +14,9 @@ import com.tonapps.extensions.pathOrNull import com.tonapps.extensions.query import com.tonapps.extensions.queryBoolean import com.tonapps.extensions.queryLong -import com.tonapps.extensions.queryPositiveLong import com.tonapps.extensions.toUriOrNull import org.ton.api.pub.PublicKeyEd25519 -import org.ton.block.StateInit import org.ton.cell.Cell -import org.ton.tlb.CellRef -import org.ton.tlb.asRef import java.io.File sealed class DeepLinkRoute { @@ -59,6 +53,23 @@ sealed class DeepLinkRoute { data class Collectibles( override val from: String ): Tabs("tonkeeper://collectibles", from) + + data class Trading( + override val from: String, + val shelfKey: String?, + ): Tabs(buildTradingUri(shelfKey), from) { + + private companion object { + + private fun buildTradingUri(shelfKey: String?): String { + val builder = "tonkeeper://trading".toUri().buildUpon() + if (!shelfKey.isNullOrBlank()) { + builder.appendQueryParameter("shelf", shelfKey) + } + return builder.build().toString() + } + } + } } sealed class Internal: DeepLinkRoute() @@ -67,6 +78,40 @@ sealed class DeepLinkRoute { data object Staking: DeepLinkRoute() data object Purchase: DeepLinkRoute() data object Send: DeepLinkRoute() + + data class Deposit( + val fromToken: String?, + val toToken: String?, + val fromNetwork: String?, + val toNetwork: String?, + val cashMethod: String?, + ): DeepLinkRoute() { + + constructor(uri: Uri) : this( + fromToken = uri.query("ft"), + toToken = uri.query("tt"), + fromNetwork = uri.query("fn"), + toNetwork = uri.query("tn"), + cashMethod = uri.query("cm"), + ) + } + + data class Withdraw( + val fromToken: String?, + val toToken: String?, + val fromNetwork: String?, + val toNetwork: String?, + val cashMethod: String?, + ): DeepLinkRoute() { + + constructor(uri: Uri) : this( + fromToken = uri.query("ft"), + toToken = uri.query("tt"), + fromNetwork = uri.query("fn"), + toNetwork = uri.query("tn"), + cashMethod = uri.query("cm"), + ) + } data object Settings: Internal() data object SettingsSecurity: Internal() data object SettingsCurrency: Internal() @@ -269,6 +314,16 @@ sealed class DeepLinkRoute { ) } + data class Asset(val assetId: String): DeepLinkRoute() { + + constructor(uri: Uri) : this( + assetId = uri.lastPathSegment + ?: uri.pathOrNull + ?: uri.query("id") + ?: throw IllegalArgumentException("Asset id is required") + ) + } + companion object { private const val PREFIX = "tonkeeper://" @@ -285,11 +340,14 @@ sealed class DeepLinkRoute { "backup", "backups" -> Backups "staking" -> Staking "buy-ton" -> Purchase + "deposit" -> Deposit(uri) "send" -> Send + "withdraw" -> Withdraw(uri) "wallet", "main" -> Tabs.Main(from) "activity", "history" -> Tabs.Activity(from) "browser" -> Tabs.Browser(from, uri.query("category") ?: uri.lastPathSegment) "collectibles" -> Tabs.Collectibles(from) + "trading" -> Tabs.Trading(from, uri.query("shelf") ?: uri.lastPathSegment) "settings" -> Settings "pool" -> StakingPool(uri) "swap" -> Swap(uri) @@ -316,6 +374,7 @@ sealed class DeepLinkRoute { "manage" -> ManageAssets "picker", "wallets" -> WalletPicker "jetton", "token" -> Jetton(uri) + "asset", "assets" -> Asset(uri) "story", "stories" -> Story(uri) "install" -> Install(uri) else -> throw IllegalArgumentException("Unknown domain: $domain") diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/Context.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/Context.kt new file mode 100644 index 000000000..6bb9d447b --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/Context.kt @@ -0,0 +1,17 @@ +package com.tonapps.core.extensions + +import android.content.Context +import androidx.annotation.DrawableRes +import ui.ComposeIcon + +fun Context.externalDrawableUrl(@DrawableRes resId: Int): String { + return "android.resource://$packageName/$resId" +} + +fun Context.composeIcon( + @DrawableRes resId: Int, + tintColor: Int? = null +) = ComposeIcon( + url = externalDrawableUrl(resId), + tintColor = tintColor +) diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/ContextExt.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/ContextExt.kt new file mode 100644 index 000000000..db029c0d4 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/ContextExt.kt @@ -0,0 +1,27 @@ +package com.tonapps.core.extensions + +import android.app.Activity +import android.content.ContextWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +fun rememberActivity(): Activity { + val context = LocalContext.current + val activity = remember(context) { + var ptr = context + + while (true) { + when (ptr) { + is Activity -> return@remember ptr + is ContextWrapper -> ptr = ptr.baseContext + else -> break + } + } + + error("No activity found") + } + + return activity +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/WalletCurrency.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/WalletCurrency.kt new file mode 100644 index 000000000..78b6ea01b --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/extensions/WalletCurrency.kt @@ -0,0 +1,25 @@ +package com.tonapps.core.extensions + +import android.content.Context +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.uikit.icon.UIKitIcon + +fun WalletCurrency.Chain.iconExternalUrl(context: Context): String? { + return when (this) { + is WalletCurrency.Chain.TON -> context.externalDrawableUrl(UIKitIcon.ic_ton) + is WalletCurrency.Chain.ETC, + is WalletCurrency.Chain.ETHEREUM -> context.externalDrawableUrl(UIKitIcon.ic_eth) + is WalletCurrency.Chain.TRON -> context.externalDrawableUrl(UIKitIcon.ic_tron) + else -> null + } +} + +fun WalletCurrency.iconExternalUrl(context: Context): String? { + return if (this == WalletCurrency.TON) { + context.externalDrawableUrl(UIKitIcon.ic_gram_with_bg) + } else if (code == WalletCurrency.USDT_KEY) { + context.externalDrawableUrl(UIKitIcon.ic_usdt) + } else { + iconUrl + } +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/TooltipKey.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/TooltipKey.kt new file mode 100644 index 000000000..6df2be4c2 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/TooltipKey.kt @@ -0,0 +1,13 @@ +package com.tonapps.core.flags + +enum class TooltipState { + ALWAYS, + SHOWN, + NOT_SHOWN; +} + +sealed interface TooltipKey { + val tooltipName: String + val defaultState: TooltipState + val maxTimeToShow: Int +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/TooltipManager.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/TooltipManager.kt new file mode 100644 index 000000000..6e7be1ee2 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/TooltipManager.kt @@ -0,0 +1,93 @@ +package com.tonapps.core.flags + +import android.annotation.SuppressLint +import android.content.Context +import androidx.core.content.edit + +@SuppressLint("StaticFieldLeak") +object TooltipManager { + + private const val PREFERENCES_NAME = "AppTooltipManager" + + private lateinit var context: Context + + private val prefs by lazy { + context.getSharedPreferences( + PREFERENCES_NAME, + Context.MODE_PRIVATE + ) + } + + fun initialize(context: Context) { + this.context = context + } + + fun setState(tooltip: TooltipKey, state: TooltipState) { + prefs.edit { + putString(tooltip.tooltipName, state.name) + } + if (state == TooltipState.NOT_SHOWN) { + resetShowCount(tooltip) + resetSession(tooltip) + } + } + + fun reset(tooltip: TooltipKey) { + prefs.edit { + remove(tooltip.tooltipName) + } + } + + fun getState(tooltip: TooltipKey): TooltipState { + val stored = prefs.getString(tooltip.tooltipName, null) + return stored?.let { TooltipState.valueOf(it) } ?: tooltip.defaultState + } + + fun shouldShow(tooltip: TooltipKey): Boolean { + when (getState(tooltip)) { + TooltipState.SHOWN -> return false + TooltipState.ALWAYS -> return true + else -> Unit + } + + if (getShowCount(tooltip) >= tooltip.maxTimeToShow) { + return false + } + + if (wasShownInSession(tooltip)) { + return false + } + + return true + } + + fun getShowCount(tooltip: TooltipKey): Int { + return prefs.getInt("${tooltip.tooltipName}_count", 0) + } + + fun incrementShowCount(tooltip: TooltipKey) { + prefs.edit { + putInt("${tooltip.tooltipName}_count", getShowCount(tooltip) + 1) + } + } + + private fun resetShowCount(tooltip: TooltipKey) { + prefs.edit { + remove("${tooltip.tooltipName}_count") + } + } + + private val shownInSession = mutableSetOf() + + fun wasShownInSession(tooltip: TooltipKey): Boolean { + return tooltip.tooltipName in shownInSession + } + + fun markShownInSession(tooltip: TooltipKey) { + shownInSession.add(tooltip.tooltipName) + } + + private fun resetSession(tooltip: TooltipKey) { + shownInSession.remove(tooltip.tooltipName) + } +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/WalletTooltip.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/WalletTooltip.kt new file mode 100644 index 000000000..62e2f2e7e --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/flags/WalletTooltip.kt @@ -0,0 +1,26 @@ +package com.tonapps.core.flags + +enum class WalletTooltipKey( + override val tooltipName: String, + override val maxTimeToShow: Int, + override val defaultState: TooltipState = TooltipState.NOT_SHOWN, +) : TooltipKey { + HISTORY_HERE("history_here_tooltip", 1), + TRADING_TAB("trading_tab_tooltip", 1), + ; +} + +sealed interface WalletTooltip { + val key: TooltipKey + + val shouldShow: Boolean get() = TooltipManager.shouldShow(key) + val state: TooltipState get() = TooltipManager.getState(key) + + data object HistoryHere : WalletTooltip { + override val key: TooltipKey get() = WalletTooltipKey.HISTORY_HERE + } + + data object TradingTab : WalletTooltip { + override val key: TooltipKey get() = WalletTooltipKey.TRADING_TAB + } +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ClipboardManager.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ClipboardManager.kt new file mode 100644 index 000000000..126442217 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ClipboardManager.kt @@ -0,0 +1,60 @@ +package com.tonapps.core.helper + +import android.content.ClipData +import android.content.ClipDescription +import android.content.Context +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.tonapps.extensions.lazyUnsafe +import com.tonapps.uikit.color.backgroundContentTintColor +import com.tonapps.wallet.localization.Localization +import uikit.extensions.activity +import uikit.navigation.NavigationActivity + +class ClipboardManager( + private val context: NavigationActivity?, +) { + + fun copy(text: String, message: String? = null) { + val context = context ?: return + val message = message ?: context.getString(Localization.copied) + + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clip = ClipData.newPlainText("", text) + clipboard.setPrimaryClip(clip) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + context.toast(message, false, context.backgroundContentTintColor) + } + } + + fun getText(): String? { + val context = context ?: return null + + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clipDescription = clipboard.primaryClipDescription + + return if ( + clipboard.hasPrimaryClip() + || (clipDescription != null + && clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)) + ) { + clipboard.primaryClip + ?.getItemAt(0) + ?.text + ?.toString() + } else { + null + } + } +} + +@Composable +fun rememberClipboardManager(): ClipboardManager { + val context = LocalContext.current + val activity = remember(context) { context.activity } + val manager = remember(context) { ClipboardManager(activity) } + return manager +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/EnvironmentHelper.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/EnvironmentHelper.kt new file mode 100644 index 000000000..934a30cd9 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/EnvironmentHelper.kt @@ -0,0 +1,23 @@ +package com.tonapps.core.helper + +class EnvironmentHelper( + private val delegate: Delegate +) { + interface Delegate { + fun deviceCountry(): String + fun storeCountry(): String? + fun simCountry(): String? + fun timezone(): String? + fun isVpnActive(): Boolean + fun locale(): String + fun currency(): String + } + + fun deviceCountry(): String = delegate.deviceCountry() + fun storeCountry(): String? = delegate.storeCountry() + fun simCountry(): String? = delegate.simCountry() + fun timezone(): String? = delegate.timezone() + fun isVpnActive(): Boolean = delegate.isVpnActive() + fun locale(): String = delegate.locale() + fun currency(): String = delegate.currency() +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/ExternalLinkHelper.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ExternalLinkHelper.kt similarity index 90% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/ExternalLinkHelper.kt rename to apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ExternalLinkHelper.kt index 79c4a4dc2..67bfb947e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/ExternalLinkHelper.kt +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ExternalLinkHelper.kt @@ -1,4 +1,4 @@ -package com.tonapps.tonkeeper.helper +package com.tonapps.core.helper object ExternalLinkHelper { diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ShareManager.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ShareManager.kt new file mode 100644 index 000000000..432de4baa --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/ShareManager.kt @@ -0,0 +1,28 @@ +package com.tonapps.core.helper + +import android.app.Activity +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.tonapps.core.extensions.rememberActivity +import com.tonapps.wallet.localization.Localization + +class ShareManager( + private val activity: Activity, +) { + private val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + } + + fun share(text: String) { + intent.putExtra(Intent.EXTRA_TEXT, text) + activity.startActivity(Intent.createChooser(intent, activity.getString(Localization.share))) + } +} + +@Composable +fun rememberShareManager(): ShareManager { + val activity = rememberActivity() + val manager = remember { ShareManager(activity) } + return manager +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/T.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/T.kt new file mode 100644 index 000000000..eb2ae360a --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/T.kt @@ -0,0 +1,20 @@ +package com.tonapps.core.helper + +import android.annotation.SuppressLint +import android.content.Context +import android.widget.Toast + +@SuppressLint("StaticFieldLeak") +object T { + + private lateinit var context: Context + + fun initialize(context: Context) { + this.context = context + } + + fun show(message: String) { + Toast.makeText(context, message, Toast.LENGTH_LONG) + .show() + } +} diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/WalletRedMetadata.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/WalletRedMetadata.kt new file mode 100644 index 000000000..ca370bd55 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/helper/WalletRedMetadata.kt @@ -0,0 +1,12 @@ +package com.tonapps.core.helper + +import com.tonapps.bus.core.contract.RedMetadata +import com.tonapps.core.flags.WalletFeature + +object WalletRedMetadata { + fun walletKit(isEnabled: Boolean = WalletFeature.WalletKitEnabled.isEnabled): String { + return RedMetadata.builder { + add("is_wallet_kit_enabled", isEnabled) + } + } +} \ No newline at end of file diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/mvcc/MvccFeature.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/mvcc/MvccFeature.kt new file mode 100644 index 000000000..9a2ff797c --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/mvcc/MvccFeature.kt @@ -0,0 +1,143 @@ +package com.tonapps.core.mvcc + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.incrementAndFetch + +sealed interface Action { + data object PreLoad : Action + data object Refresh : Action + data object Load : Action +} + +// Init first data +// Load first page from cache +// Load first page from remote +// Refresh +// Additional load +class MvccFeature : ViewModel() { + + val main = viewModelScope + val stateD = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + val state = CoroutineScope(viewModelScope.coroutineContext + stateD) + val io = CoroutineScope(viewModelScope.coroutineContext + Dispatchers.IO) + val default = CoroutineScope(viewModelScope.coroutineContext + Dispatchers.Default) + + val mvcc = Mvcc() + val repo = Repo() + + suspend fun execute(action: Action) { + state.launch { + when (action) { + Action.PreLoad -> execute { + val task = repo.loadCommonData() + setState { task } + + val firstPage = repo.loadFirstPageLocal() + val secondPage = io.async { repo.loadFirstPageRemote() } + setState { firstPage } + + val result = secondPage.await() + setState { result } + } + Action.Load -> execute { + val secondPage = repo.loadNextPage() + setState { secondPage } + } + Action.Refresh -> reset { + val task = io.async { repo.loadCommonData() } + setState { "Refresh" } + } + } + } + } + + suspend fun reset(invoke: suspend Int.() -> Unit) { + coroutineScope { + val version = mvcc.acquire() + mvcc.commit(version - 1) + + try { + invoke(version) + } finally { + mvcc.commit(version) + } + } + } + + suspend fun Int.setState(builder: () -> String) { + mvcc.await(this) + builder() + } + + suspend fun execute(invoke: suspend Int.() -> Unit) { + coroutineScope { + val version = mvcc.acquire() + + try { + invoke(version) + } finally { + mvcc.commit(version) + } + } + } + + suspend fun Int.await(after: suspend () -> Unit) { + mvcc.await(this) + after() + } + + fun handleLoad() { + + } +} + +class Mvcc { + + private val awaited = AtomicInt(0) + private val commited = AtomicInt(0) + private val awaiter = HashMap>() + + fun acquire(): Int { + return awaited.incrementAndFetch() + } + + fun commit(version: Int) { + commited.compareAndSet(version - 1, version) + awaiter[version]?.trySend(Unit) + } + + suspend fun await(version: Int) { + val new = Channel() // TODO if nore - fail + awaiter[version - 1] = new + new.receive() + } +} + +class Repo { + suspend fun loadCommonData(): String { + return "Result" + } + + suspend fun loadFirstPageLocal(): String { + return "Result" + } + + suspend fun loadFirstPageRemote(): String { + return "Result" + } + + suspend fun loadNextPage(): String { + return "Result" + } +} \ No newline at end of file diff --git a/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/navigation/ResultStore.kt b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/navigation/ResultStore.kt new file mode 100644 index 000000000..f35029ce1 --- /dev/null +++ b/apps/wallet/features/core/src/main/kotlin/com/tonapps/core/navigation/ResultStore.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tonapps.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ProvidedValue +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver + +/** + * Local for storing results in a [ResultStore] + */ +object LocalResultStore { + private val LocalResultStore: ProvidableCompositionLocal = + compositionLocalOf { null } + + /** + * The current [ResultStore] + */ + val current: ResultStore + @Composable + get() = LocalResultStore.current ?: error("No ResultStore has been provided") + + /** + * Provides a [ResultStore] to the composition + */ + infix fun provides( + store: ResultStore + ): ProvidedValue { + return LocalResultStore.provides(store) + } +} + +/** + * Provides a [ResultStore] that will be remembered across configuration changes. + */ +@Composable +fun rememberResultStore() : ResultStore { + return remember { ResultStore() } +} + +/** + * A store for passing results between multiple sets of screens. + * + * It provides a solution for state based results. + */ +class ResultStore { + + /** + * Map from the result key to a mutable state of the result. + */ + val resultStateMap = mutableStateMapOf>() + + /** + * Retrieves the current result of the given resultKey. + */ + inline fun getResult(resultKey: String = T::class.toString()): T? { + return resultStateMap[resultKey] + ?.let { it.value as T } + } + + + /** + * Sets the result for the given resultKey. + */ + inline fun setResult(resultKey: String = T::class.toString(), result: T) { + resultStateMap[resultKey] = mutableStateOf(result) + } + + /** + * Removes all results associated with the given key from the store. + */ + inline fun removeResult(resultKey: String = T::class.toString()): T? { + return resultStateMap.remove(resultKey) + ?.let { it.value as T } + } +} + +/** Saver to save and restore the NavController across config change and process death. */ +private fun ResultStoreSaver(): Saver = + Saver( + save = { it.resultStateMap }, + restore = { ResultStore().apply { resultStateMap.putAll(it) } }, + ) \ No newline at end of file diff --git a/apps/wallet/features/core/src/main/res/layout/fragment_compose_host.xml b/apps/wallet/features/core/src/main/res/layout/fragment_compose_host.xml new file mode 100644 index 000000000..5690a7094 --- /dev/null +++ b/apps/wallet/features/core/src/main/res/layout/fragment_compose_host.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/apps/wallet/features/dapp/build.gradle.kts b/apps/wallet/features/dapp/build.gradle.kts new file mode 100644 index 000000000..fe078d7e8 --- /dev/null +++ b/apps/wallet/features/dapp/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("target.android.compose") + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.compose.debugTooling) + + implementation(libs.bundles.nav3) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.core) + implementation(libs.koin.compose) + + implementation(projects.apps.wallet.localization) + + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.dapps) + implementation(projects.apps.wallet.features.core) + + implementation(projects.kmp.core) + implementation(projects.kmp.ui) + implementation(projects.kmp.mvi) + implementation(projects.kmp.async) + + implementation(projects.ui.uikit.icon) + implementation(projects.ui.uikit.core) + + implementation(projects.lib.icu) + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) +} diff --git a/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmFeature.kt b/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmFeature.kt new file mode 100644 index 000000000..d27d49435 --- /dev/null +++ b/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmFeature.kt @@ -0,0 +1,70 @@ +package com.tonapps.dapp.warning + +import android.net.Uri +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.dapps.entities.AppEntity +import com.tonapps.wallet.data.settings.SettingsRepository + +sealed interface DAppConfirmAction : MviAction { + object Init : DAppConfirmAction + data class UpdateCheckbox(val checked: Boolean) : DAppConfirmAction +} + +data class DAppConfirmState( + val host: String = "", + val iconUrl: String = "", + val name: String = "" +) : MviState + +class DAppConfirmViewState( + val global: MviProperty +) : MviViewState + +data class DAppConfirmData( + val walletId: String, + val app: AppEntity, + val dAppUrl: Uri +) + +class DAppConfirmFeature( + private val data: DAppConfirmData, + private val settingsRepository: SettingsRepository, + private val accountRepository: AccountRepository, +) : MviFeature( + initState = DAppConfirmState(), + initAction = DAppConfirmAction.Init +) { + + override fun createViewState(): DAppConfirmViewState { + return buildViewState { + DAppConfirmViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: DAppConfirmAction) { + when (action) { + is DAppConfirmAction.Init -> initialize() + is DAppConfirmAction.UpdateCheckbox -> updateCheckbox(action.checked) + } + } + + private fun initialize() { + setState { + copy( + host = data.dAppUrl.host ?: data.app.host, + iconUrl = data.app.iconUrl, + name = data.app.name + ) + } + } + + private suspend fun updateCheckbox(checked: Boolean) { + val wallet = accountRepository.getWalletById(data.walletId) ?: return // TODO Show Error + settingsRepository.setDAppOpenConfirm(wallet.id, data.app.host, !checked) + } +} diff --git a/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmFragment.kt b/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmFragment.kt new file mode 100644 index 000000000..5fe1ddf07 --- /dev/null +++ b/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmFragment.kt @@ -0,0 +1,81 @@ +package com.tonapps.dapp.warning + +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.core.net.toUri +import com.tonapps.core.ComposableFragment +import com.tonapps.extensions.getParcelableCompat +import com.tonapps.wallet.data.dapps.entities.AppEntity +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import uikit.base.BaseFragment + +class DAppConfirmFragment : ComposableFragment(), BaseFragment.Modal { + + interface Delegate { + fun openDapp(walletId: String, app: AppEntity, dAppUrl: Uri, source: String) + } + + override val fragmentName: String = "DAppShareScreen" + + private val walletId: String + get() = requireArguments().getString(ARG_WALLET)!! + + private val app: AppEntity + get() = requireArguments().getParcelableCompat(ARG_APP)!! + + private val dAppUrl: Uri + get() = requireArguments().getString(ARG_URL)!!.toUri() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val data = DAppConfirmData( + walletId = walletId, + app = app, + dAppUrl = dAppUrl + ) + + setContent { + val viewModel = koinViewModel { + parametersOf(data) + } + + DAppConfirmScreen( + viewModel = viewModel, + onOpen = { openDApp() }, + onFinishClick = { finish() } + ) + } + } + + private fun openDApp() { + context?.let { + if (it is Delegate) { + it.openDapp( + walletId = walletId, + app = app, + dAppUrl = dAppUrl, + source = "deep-link" + ) + } + } + + finish() + } + + companion object { + private const val ARG_WALLET = "wallet" + private const val ARG_APP = "app" + private const val ARG_URL = "url" + + fun newInstance(walletId: String, app: AppEntity, url: Uri): BaseFragment { + val screen = DAppConfirmFragment() + screen.putStringArg(ARG_WALLET, walletId) + screen.putParcelableArg(ARG_APP, app) + screen.putStringArg(ARG_URL, url.toString()) + return screen + } + } +} diff --git a/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmScreen.kt b/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmScreen.kt new file mode 100644 index 000000000..76369c30a --- /dev/null +++ b/apps/wallet/features/dapp/src/main/kotlin/com/tonapps/dapp/warning/DAppConfirmScreen.kt @@ -0,0 +1,162 @@ +package com.tonapps.dapp.warning + +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonAsyncImage +import ui.components.moon.MoonTopAppBar +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.cell.MoonInfoCell +import ui.components.moon.cell.MoonTextCheckboxCell +import ui.components.moon.cell.MoonTextContentCell +import ui.preview.ThemedPreview +import ui.theme.Dimens +import ui.theme.UIKit + +@Preview +@Composable +private fun DAppConfirmScreenPreview() { + ThemedPreview { + Content( + data = DAppConfirmState( + host = "https://uniswap.com", + name = "Uniswap" + ), + onOpen = {}, + onFinishClick = {}, + onChecked = {} + ) + } +} + +@Composable +fun DAppConfirmScreen( + viewModel: DAppConfirmFeature, + onOpen: () -> Unit, + onFinishClick: () -> Unit, +) { + val global by viewModel.state.global.observeSafeState() + Content( + data = global, + onOpen = onOpen, + onFinishClick = onFinishClick, + onChecked = { isChecked -> viewModel.sendAction(DAppConfirmAction.UpdateCheckbox(isChecked)) } + ) +} + +@Composable +private fun Content( + data: DAppConfirmState, + onOpen: () -> Unit, + onFinishClick: () -> Unit, + onChecked: (Boolean) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + ) { + MoonTopAppBar( + title = "", + actionIconRes = UIKitIcon.ic_close_16, + onActionClick = { onFinishClick() }, + ignoreSystemOffset = true, + showDivider = false, + backgroundColor = Color.Transparent + ) + + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + DAppIcon(icon = remember(data.iconUrl) { data.iconUrl.toUri() }) + Spacer(modifier = Modifier.height(Dimens.offsetMedium)) + + MoonTextContentCell( + title = data.name, + description = data.host, // TODO replace description + ) + Spacer(modifier = Modifier.height(Dimens.offsetLarge)) + + MoonInfoCell( + text = stringResource(id = Localization.dapp_disclaimer), + painter = painterResource(id = UIKitIcon.ic_exclamationmark_circle_16) + ) + + MoonButtonCell( + onClick = onOpen, + text = stringResource(id = Localization.open) + ) + + var isChecked by remember { mutableStateOf(false) } + + MoonTextCheckboxCell( + text = stringResource(id = Localization.do_not_show_again), + isChecked = isChecked, + onCheckedChanged = { + isChecked = it + onChecked(isChecked) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } +} + +@Composable +private fun DAppIcon( + icon: Uri? +) { + Box(modifier = Modifier.padding(Dimens.offsetMedium)) { + Box( + modifier = Modifier + .size(96.dp) + .clip(RoundedCornerShape(24.dp)) + .background(UIKit.colorScheme.background.content), + contentAlignment = Alignment.Center + ) { + if (icon != null) { + MoonAsyncImage( + image = icon, + size = 96.dp, + ) + } else { + Image( + painter = painterResource(id = UIKitIcon.ic_globe_56), + contentDescription = null, + colorFilter = ColorFilter.tint(UIKit.colorScheme.icon.secondary) + ) + } + } + } +} diff --git a/apps/wallet/features/embeded/scanner/build.gradle.kts b/apps/wallet/features/embeded/scanner/build.gradle.kts new file mode 100644 index 000000000..cde044a7f --- /dev/null +++ b/apps/wallet/features/embeded/scanner/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("target.android.compose") + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.compose.debugTooling) + + implementation(libs.bundles.nav3) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.core) + implementation(libs.koin.compose) + + implementation(projects.apps.wallet.localization) + + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.dapps) + implementation(projects.apps.wallet.features.core) + + implementation(projects.kmp.core) + implementation(projects.kmp.ui) + implementation(projects.kmp.mvi) + implementation(projects.kmp.async) + + implementation(projects.ui.uikit.icon) + implementation(projects.ui.uikit.core) + + implementation(projects.lib.icu) + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) + implementation(projects.lib.qr) + + implementation(libs.camerax.base) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) +} diff --git a/apps/wallet/features/embeded/scanner/src/main/kotlin/com/tonapps/scanner/ScannerScreen.kt b/apps/wallet/features/embeded/scanner/src/main/kotlin/com/tonapps/scanner/ScannerScreen.kt new file mode 100644 index 000000000..07ebfbe6a --- /dev/null +++ b/apps/wallet/features/embeded/scanner/src/main/kotlin/com/tonapps/scanner/ScannerScreen.kt @@ -0,0 +1,334 @@ +package com.tonapps.scanner + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.TorchState +import androidx.camera.view.CameraController +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.mlkit.vision.common.InputImage +import com.tonapps.qr.QRImageAnalyzer +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import ui.components.moon.MoonTopAppBar +import ui.components.moon.container.MoonSurface +import ui.components.moon.dialog.MoonModalDialog +import ui.components.moon.dialog.rememberDialogNavigator +import ui.theme.UIKit +import java.util.concurrent.Executors + +// TODO refactor later +@Composable +fun ScannerScreen( + onResult: (String) -> Unit, + onClose: () -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + hasCameraPermission = granted + } + + LaunchedEffect(Unit) { + if (!hasCameraPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + val qrAnalyzer = remember { QRImageAnalyzer() } + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + + var flashEnabled by remember { mutableStateOf(false) } + var flashAvailable by remember { mutableStateOf(false) } + + val cameraController = remember { + LifecycleCameraController(context).apply { + isTapToFocusEnabled = true + setEnabledUseCases(CameraController.IMAGE_ANALYSIS) + cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA + imageAnalysisBackpressureStrategy = ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST + imageAnalysisOutputImageFormat = ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888 + } + } + + val galleryLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri -> + if (uri != null) { + try { + val inputImage = InputImage.fromFilePath(context, uri) + qrAnalyzer.barcodeScanner.process(inputImage) + .addOnSuccessListener { barcodes -> + barcodes.firstOrNull()?.rawValue?.let(onResult) + } + } catch (_: Throwable) { + } + } + } + + // Collect QR scan results + LaunchedEffect(qrAnalyzer) { + qrAnalyzer.flow + .map { it.rawValue } + .filterNotNull() + .collect { value -> + withContext(Dispatchers.Main) { + onResult(value) + } + } + } + + // Start camera when permission is granted + LaunchedEffect(hasCameraPermission) { + if (hasCameraPermission) { + cameraController.setImageAnalysisAnalyzer(cameraExecutor, qrAnalyzer) + cameraController.torchState.observe(lifecycleOwner) { state -> + flashAvailable = true + flashEnabled = state == TorchState.ON + } + } + } + + DisposableEffect(Unit) { + onDispose { + cameraController.unbind() + qrAnalyzer.release() + cameraExecutor.shutdown() + } + } + + MoonSurface(Modifier.fillMaxSize(), color = Color.Black, shape = RectangleShape) { + Box( + Modifier.fillMaxSize() + ) { + // Camera preview + if (hasCameraPermission) { + AndroidView( + factory = { ctx -> + PreviewView(ctx).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + controller = cameraController + cameraController.bindToLifecycle(lifecycleOwner) + cameraController.cameraControl?.setLinearZoom(0.1f) + cameraController.cameraControl?.setExposureCompensationIndex(1) + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + + CameraOverlay( + modifier = Modifier.fillMaxSize(), + title = stringResource(Localization.scan_qr_code), + flashAvailable = flashAvailable, + flashEnabled = flashEnabled, + onFlashClick = { cameraController.enableTorch(!flashEnabled) }, + ) + + // Top bar - close button + val dimWhite = remember { Color.White.copy(alpha = 0.08f) } // TODO + Box( + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 16.dp, top = 16.dp) + .size(32.dp) + .clip(CircleShape) + .background(dimWhite) + .clickable (onClick = { onClose() }), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(UIKitIcon.ic_chevron_left_16), + contentDescription = null, + tint = Color.White, + ) + } + + // Bottom: gallery button + Text( + text = stringResource(Localization.select_from_gallery), + style = UIKit.typography.label1, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(bottom = 24.dp) + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xFF454545)) + .clickable { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + .padding(horizontal = 26.dp, vertical = 12.dp), + ) + } + } +} + +@Composable +private fun CameraOverlay( + modifier: Modifier = Modifier, + title: String, + flashAvailable: Boolean, + flashEnabled: Boolean, + onFlashClick: () -> Unit, +) { + val dimColor = remember { Color(0xCC000000) } + val dimWhite = remember { Color.White.copy(alpha = 0.08f) } + val cornerRadiusDp = 20.dp + val maxSizeDp = 350.dp + val bracketLengthDp = 32.dp + val strokeWidthDp = 4.dp + + Box(modifier = modifier) { + Canvas( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }, + ) { + val cutoutSize = minOf(size.width, size.height) - 64.dp.toPx() + val finalSize = cutoutSize.coerceAtMost(maxSizeDp.toPx()) + val left = (size.width - finalSize) / 2f + val top = (size.height - finalSize) / 2f + val cornerRadius = cornerRadiusDp.toPx() + val bracketLength = bracketLengthDp.toPx() + + drawRect(dimColor) + + drawRoundRect( + color = Color.Transparent, + topLeft = Offset(left, top), + size = Size(finalSize, finalSize), + cornerRadius = CornerRadius(cornerRadius), + blendMode = BlendMode.Clear, + ) + + val path = Path() + path.moveTo(left, top + bracketLength) + path.lineTo(left, top) + path.lineTo(left + bracketLength, top) + + path.moveTo(left + finalSize - bracketLength, top) + path.lineTo(left + finalSize, top) + path.lineTo(left + finalSize, top + bracketLength) + + path.moveTo(left, top + finalSize - bracketLength) + path.lineTo(left, top + finalSize) + path.lineTo(left + bracketLength, top + finalSize) + + path.moveTo(left + finalSize - bracketLength, top + finalSize) + path.lineTo(left + finalSize, top + finalSize) + path.lineTo(left + finalSize, top + finalSize - bracketLength) + + drawPath( + path = path, + color = Color.White, + style = Stroke( + width = strokeWidthDp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Miter, + pathEffect = PathEffect.cornerPathEffect(cornerRadius), + ), + ) + } + + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically), + ) { + Text( + text = title, + style = UIKit.typography.h2, + color = Color.White, + ) + + Spacer(modifier = Modifier.size(maxSizeDp)) + + if (flashAvailable) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(if (flashEnabled) Color.White else dimWhite) + .clickable(onClick = onFlashClick), + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource(UIKitIcon.ic_flash), + contentDescription = null, + tint = if (flashEnabled) Color.Black else Color.White, + modifier = Modifier.size(24.dp), + ) + } + } + } + } +} diff --git a/apps/wallet/features/events/build.gradle.kts b/apps/wallet/features/events/build.gradle.kts new file mode 100644 index 000000000..51c46029d --- /dev/null +++ b/apps/wallet/features/events/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + id("target.android.library") +} + +dependencies { + implementation(libs.koin.core) + implementation(libs.kotlinx.collections.immutable) + + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.data.events) + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.localization) + implementation(projects.apps.wallet.features.core) + + implementation(projects.kmp.ui) + implementation(projects.ui.uikit.icon) + + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) +} diff --git a/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/ActionTypeUi.kt b/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/ActionTypeUi.kt new file mode 100644 index 000000000..a21f115ef --- /dev/null +++ b/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/ActionTypeUi.kt @@ -0,0 +1,52 @@ +package com.tonapps.wallet.features.events + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.data.events.ActionType +import com.tonapps.wallet.localization.Localization + +@get:DrawableRes +val ActionType.iconRes: Int + get() = when (this) { + ActionType.Received, ActionType.NftReceived, ActionType.JettonMint -> UIKitIcon.ic_arrow_down_28 + ActionType.Send, ActionType.NftSend, ActionType.AuctionBid -> UIKitIcon.ic_tray_arrow_up_28 + ActionType.CallContract, ActionType.DepositStake, ActionType.Unknown -> UIKitIcon.ic_gear_28 + ActionType.Swap -> UIKitIcon.ic_swap_horizontal_outline_28 + ActionType.SetSignatureAllowed, ActionType.AddExtension, ActionType.DeployContract, + ActionType.WithdrawStakeRequest, ActionType.WithdrawStake -> UIKitIcon.ic_donemark_28 + ActionType.DomainRenewal -> UIKitIcon.ic_update_24 + ActionType.NftPurchase, ActionType.Purchase -> UIKitIcon.ic_creditcard_28 + ActionType.JettonBurn, ActionType.GasRelay -> UIKitIcon.ic_flash_24 + ActionType.SetSignatureNotAllowed, ActionType.RemoveExtension, ActionType.UnSubscribe -> UIKitIcon.ic_xmark_circle_16 + ActionType.Subscribe -> UIKitIcon.ic_bell_28 + ActionType.Fee, ActionType.Refund -> UIKitIcon.ic_gram_28 + } + +@get:StringRes +val ActionType.nameRes: Int + get() = when (this) { + ActionType.Received, ActionType.NftReceived, ActionType.JettonMint -> Localization.received + ActionType.Send, ActionType.NftSend -> Localization.sent + ActionType.CallContract -> Localization.call_contract + ActionType.Swap -> Localization.swap + ActionType.DeployContract -> Localization.wallet_initialized + ActionType.DepositStake -> Localization.stake + ActionType.AuctionBid -> Localization.bid + ActionType.WithdrawStakeRequest -> Localization.unstake_request + ActionType.DomainRenewal -> Localization.domain_renew + ActionType.WithdrawStake -> Localization.unstake + ActionType.Unknown -> Localization.unknown + ActionType.NftPurchase -> Localization.nft_purchase + ActionType.JettonBurn -> Localization.burned + ActionType.UnSubscribe -> Localization.unsubscribed + ActionType.Subscribe -> Localization.subscribed + ActionType.Fee -> Localization.network_fee + ActionType.Refund -> Localization.refund + ActionType.Purchase -> Localization.purchase + ActionType.GasRelay -> Localization.battery + ActionType.AddExtension -> Localization.added_extension + ActionType.RemoveExtension -> Localization.removed_extension + ActionType.SetSignatureAllowed -> Localization.signature_allowed + ActionType.SetSignatureNotAllowed -> Localization.signature_not_allowed + } diff --git a/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/Module.kt b/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/Module.kt new file mode 100644 index 000000000..8d716c2ee --- /dev/null +++ b/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/Module.kt @@ -0,0 +1,7 @@ +package com.tonapps.wallet.features.events + +import org.koin.dsl.module + +val eventsFeatureModule = module { + single { TxEventUiMapper(get(), get(), get()) } +} diff --git a/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/TxEventUiMapper.kt b/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/TxEventUiMapper.kt new file mode 100644 index 000000000..89c081a3f --- /dev/null +++ b/apps/wallet/features/events/src/main/kotlin/com/tonapps/wallet/features/events/TxEventUiMapper.kt @@ -0,0 +1,213 @@ +package com.tonapps.wallet.features.events + +import android.content.Context +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.core.extensions.externalDrawableUrl +import com.tonapps.extensions.locale +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.data.events.ActionType +import com.tonapps.wallet.data.events.EventsRepository +import com.tonapps.wallet.data.events.tx.model.TxAction +import com.tonapps.wallet.data.events.tx.model.TxActionBody +import com.tonapps.wallet.data.events.tx.model.TxEvent +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.settings.SpamTransactionState +import com.tonapps.wallet.localization.Localization +import kotlinx.collections.immutable.toImmutableList +import ui.components.events.UiEvent +import ui.uiPosition +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +class TxEventUiMapper( + private val context: Context, + private val eventsRepository: EventsRepository, + private val settingsRepository: SettingsRepository, +) { + + private val moreButtonText = context.getString(Localization.more) + + fun changeText(item: UiEvent.Item, text: String): UiEvent.Item { + val index = item.actions.indexOfFirst { + it.text is UiEvent.Item.Action.Text.Encrypted + } + if (index == -1) { + return item + } + val action = item.actions[index].copy(text = UiEvent.Item.Action.Text.Plain(text, moreButtonText)) + val actions = item.actions.toMutableList() + actions[index] = action + return item.copy(actions = actions.toImmutableList()) + } + + private fun product(action: TxAction): UiEvent.Item.Action.Product? { + val product = action.product ?: return null + var subtitle = product.subtitle + var type = UiEvent.Item.Action.Product.Type.Default + if (action.hasUnverifiedNft) { + subtitle = context.getString(Localization.nft_unverified) + type = UiEvent.Item.Action.Product.Type.Wrong + } else if (action.hasVerifiedNft) { + type = UiEvent.Item.Action.Product.Type.Verified + } + return UiEvent.Companion.product( + title = product.title, + subtitle = subtitle.ifBlank { context.getString(Localization.unnamed_collection) }, + imageUrl = product.imageUrl, + type = type, + ) + } + + private fun text(hash: String, text: TxActionBody.Text): UiEvent.Item.Action.Text? { + if (text is TxActionBody.Text.Plain) { + return UiEvent.Companion.textPlain( + text = text.text, + moreButtonText = moreButtonText + ) + } else if (text is TxActionBody.Text.Encrypted) { + val decryptedComment = eventsRepository.getDecryptedComment(hash) + return if (decryptedComment.isNullOrBlank()) { + UiEvent.Companion.textEncrypted(context.getString(Localization.encrypted_comment)) + } else { + UiEvent.Companion.textPlain( + text = decryptedComment, + moreButtonText = moreButtonText + ) + } + } + return null + } + + private fun amount(action: TxAction): Pair { + var incomingFormatted = action.incomingFormatted + var outgoingFormatted = action.outgoingFormatted + + if (action.type == ActionType.Unknown) { + outgoingFormatted = action.body.value + } else if (action.type == ActionType.NftSend) { + outgoingFormatted = context.getString(Localization.nft) + } else if (action.type == ActionType.NftReceived || action.type == ActionType.NftPurchase) { + incomingFormatted = context.getString(Localization.nft) + } else { + if (!incomingFormatted.isNullOrBlank()) { + incomingFormatted = CurrencyFormatter.PREFIX_PLUS + incomingFormatted + } + if (!outgoingFormatted.isNullOrBlank()) { + outgoingFormatted = CurrencyFormatter.PREFIX_MINUS + outgoingFormatted + } + } + return incomingFormatted?.toString() to outgoingFormatted?.toString() + } + + private fun isSpam(event: TxEvent, wallet: WalletEntity): Boolean { + val localSpamState = settingsRepository.getSpamStateTransaction(wallet.id, event.hash) + return if (localSpamState == SpamTransactionState.UNKNOWN) { + event.spam + } else { + localSpamState == SpamTransactionState.SPAM + } + } + + private fun state(event: TxEvent, action: TxAction): UiEvent.Item.Action.State { + return when { + event.inProgress -> UiEvent.Item.Action.State.Pending + action.status == TxAction.Status.Failed -> UiEvent.Item.Action.State.Failed + else -> UiEvent.Item.Action.State.Success + } + } + + fun toUiItem(event: TxEvent, wallet: WalletEntity): UiEvent.Item { + val isSpam = isSpam(event, wallet) + val actions = event.actions.mapIndexed { index, action -> + val (incomingFormatted, outgoingFormatted) = amount(action) + + val badge = if (event.isTron) context.getString(Localization.trc20) else null + val state = state(event, action) + + val title = if (isSpam) { + context.getString(Localization.spam) + } else { + action.title ?: context.getString(action.type.nameRes) + } + + val iconUrl = if (isSpam || action.isFailed) { + context.externalDrawableUrl(UIKitIcon.ic_exclamationmark_circle_28) + } else { + context.externalDrawableUrl(action.type.iconRes) + } + + UiEvent.Item.Action( + state = state, + title = title, + subtitle = action.subtitle ?: context.getString(Localization.unknown), + incomingAmount = incomingFormatted, + outgoingAmount = outgoingFormatted, + date = formatTransactionTime(event.timestamp.toLong(), context.locale), + imageUrl = action.imageUrl, + iconUrl = iconUrl, + product = product(action), + badge = badge, + text = action.text?.let { + text(event.hash, it) + }, + warningText = if (action.isFailed) context.getString(Localization.failed) else null, + rightDescription = if (action.hasUnverifiedToken) context.getString(Localization.unverified_token) else null, + spam = isSpam, + position = uiPosition(index, event.actions.size) + ) + } + + return UiEvent.Item( + id = event.id, + timestamp = event.timestamp.toLong(), + actions = actions.toImmutableList(), + filterIds = event.txFilterIds().toImmutableList(), + spam = actions.any { it.spam }, + progress = event.inProgress + ) + } + + private fun formatTransactionTime(epochMillis: Long, locale: Locale): String { + if (epochMillis <= 0) { + return "" + } + val instant = Instant.ofEpochMilli(epochMillis) + val zone = ZoneId.systemDefault() + val zdt = instant.atZone(zone) + val now = java.time.ZonedDateTime.now(zone) + val shortMonth = DateTimeFormatter.ofPattern("MMM", locale).format(zdt).replace(".", "") + "," + val month = if (locale.language == "en") { + shortMonth.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } + } else { + shortMonth + } + val time = DateTimeFormatter.ofPattern("HH:mm", locale).format(zdt) + val day = DateTimeFormatter.ofPattern("d", locale).format(zdt) + val isThisMonth = zdt.year == now.year && zdt.month == now.month + return if (isThisMonth) { + time + } else { + "$day $month $time" + } + } +} + +private fun TxAction.filterIdsForUi(): List { + val filters = mutableListOf() + if (type == ActionType.Send || type == ActionType.NftSend || type == ActionType.DepositStake || type == ActionType.Swap) { + filters.add(2) + } + if (type == ActionType.Received || type == ActionType.NftReceived || type == ActionType.WithdrawStake || + type == ActionType.Refund || type == ActionType.Swap + ) { + filters.add(3) + } + return filters +} + +private fun TxEvent.txFilterIds(): List { + return actions.flatMap { it.filterIdsForUi() }.distinct() +} diff --git a/apps/wallet/features/ramp/build.gradle.kts b/apps/wallet/features/ramp/build.gradle.kts new file mode 100644 index 000000000..4d5ce115e --- /dev/null +++ b/apps/wallet/features/ramp/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("target.android.compose") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.compose.debugTooling) + + implementation(libs.bundles.nav3) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.coil.compose) // TODO remove, use from DesSys + + implementation(projects.tonapi.legacy) + + implementation(projects.apps.wallet.api) + implementation(projects.apps.wallet.localization) + + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.tokens) + implementation(projects.apps.wallet.data.rates) + implementation(projects.apps.wallet.data.battery) + implementation(projects.apps.wallet.data.contacts) + implementation(projects.apps.wallet.data.events) + implementation(projects.apps.wallet.data.rn) + implementation(projects.apps.wallet.data.passcode) + implementation(projects.apps.wallet.data.collectibles) + + implementation(projects.apps.wallet.data.tx) + + implementation(projects.tonapi.exchange) + implementation(projects.tonapi.battery) + implementation(projects.apps.wallet.data.legacy) + + implementation(projects.apps.wallet.features.core) + implementation(projects.apps.wallet.features.dapp) + implementation(projects.apps.wallet.features.embeded.scanner) + implementation(projects.apps.wallet.data.dapps) + + implementation(projects.kmp.core) + implementation(projects.kmp.ui) + implementation(projects.kmp.mvi) + implementation(projects.kmp.async) + + implementation(projects.ui.uikit.icon) + implementation(projects.ui.uikit.core) + + implementation(projects.lib.qr) + implementation(projects.lib.bus) + implementation(projects.lib.icu) + implementation(projects.lib.ledger) + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/DepositFragment.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/DepositFragment.kt new file mode 100644 index 000000000..4293ab675 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/DepositFragment.kt @@ -0,0 +1,53 @@ +package com.tonapps.deposit + +import android.os.Bundle +import android.view.View +import com.tonapps.core.ComposableFragment +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import uikit.base.BaseFragment +import uikit.extensions.activity + +class DepositFragment : ComposableFragment(), BaseFragment.BottomSheet { + + interface Delegate { + fun onOpenProvider(url: String) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val delegate = context?.activity as? Delegate + + val startRoute = arguments?.getString(ARG_INITIAL) + ?.let { Json.decodeFromString(it) } + ?: DepositRoutes.Ramp + + setContent { + DepositRouter( + initial = startRoute, + onBack = { finish() }, + openProvider = { + finish() + delegate?.onOpenProvider(it) + }, + ) + } + } + + companion object { + private const val ARG_INITIAL = "arg_initial" + + fun create( + initial: DepositRoutes? = null, + ): DepositFragment { + return DepositFragment().apply { + arguments = Bundle().apply { + initial?.let { + putString(ARG_INITIAL, Json.encodeToString(initial)) + } + } + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/Module.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/Module.kt new file mode 100644 index 000000000..50a5ae97e --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/Module.kt @@ -0,0 +1,9 @@ +package com.tonapps.deposit + +import com.tonapps.deposit.data.ExchangeRepository +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val depositModule = module { + singleOf(::ExchangeRepository) +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/WithdrawFragment.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/WithdrawFragment.kt new file mode 100644 index 000000000..81f0dd012 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/WithdrawFragment.kt @@ -0,0 +1,72 @@ +package com.tonapps.deposit + +import android.os.Bundle +import android.view.View +import com.tonapps.core.ComposableFragment +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import uikit.base.BaseFragment +import uikit.extensions.activity +import uikit.navigation.Navigation.Companion.navigation + +class WithdrawFragment private constructor(): ComposableFragment(), BaseFragment.BottomSheet { + + interface Delegate { + fun onOpenProvider(url: String) + fun onShowError(message: String) + fun onOpenAddressBook(onResult: (String) -> Unit) + fun onBuyTon() {} + fun onGetTrx() {} + fun onRechargeBattery() {} + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val delegate = context?.activity as? Delegate + + val startRoute = requireArguments().getString(ARG_INITIAL) + ?.let { Json.decodeFromString(it) } + ?: WithdrawRoutes.Ramp + + setContent { + WithdrawRouter( + initial = startRoute, + onBack = { finish() }, + onSendSuccess = { + navigation?.openURL("tonkeeper://activity?from=send") + finish() + }, + openProvider = { + finish() + delegate?.onOpenProvider(it) + }, + onAddressBook = delegate?.let { d -> + { onResult -> d.onOpenAddressBook(onResult) } + }, + onShowError = { + delegate?.onShowError(it) + }, + onBuyTon = { delegate?.onBuyTon() }, + onGetTrx = { delegate?.onGetTrx() }, + onRechargeBattery = { delegate?.onRechargeBattery() }, + ) + } + } + + companion object { + private const val ARG_INITIAL = "arg_initial" + + fun create( + initial: WithdrawRoutes? = null, + ): WithdrawFragment { + return WithdrawFragment().apply { + arguments = Bundle().apply { + initial?.let { + putString(ARG_INITIAL, Json.encodeToString(initial)) + } + } + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/components/AssetCell.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/components/AssetCell.kt new file mode 100644 index 000000000..8a2ec1b53 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/components/AssetCell.kt @@ -0,0 +1,131 @@ +package com.tonapps.deposit.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.core.extensions.iconExternalUrl +import com.tonapps.uikit.icon.UIKitIcon +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonItemSubtitle +import ui.components.moon.MoonItemTitle +import ui.components.moon.MoonLabel +import ui.components.moon.MoonLabelColors +import ui.components.moon.MoonLabelDefault +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonBundlePosition +import ui.components.moon.cell.TextCell +import ui.components.moon.container.BadgeDirection +import ui.components.moon.container.MoonCutBadgedBox +import ui.painterResource +import ui.theme.UIKit + +@Composable +fun AssetCell( + entity: TokenEntity, + description: String, + isSelected: Boolean = false, + position: MoonBundlePosition = MoonBundlePosition.Default, + onClick: () -> Unit = {}, +) { + val context = LocalContext.current + val chainIcon = remember(entity.tokenType) { + entity.tokenType?.let { + entity.asCurrency.chain.iconExternalUrl(context) + } + } + + AssetCell( + title = entity.symbol, + position = position, + subtitle = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + MoonItemSubtitle(text = description) + } + }, + isSelected = isSelected, + standard = entity.tokenType?.fmt, + tagColor = entity.tokenType?.color(), + assetImageUrl = entity.imageUri.toString(), + chainImageUrl = chainIcon, + onClick = onClick, + ) +} + + +@Composable +fun AssetCell( + title: String, + subtitle: (@Composable () -> Unit)? = null, + assetImageUrl: String?, + chainImageUrl: String? = null, + standard: String? = null, + isExpanded: Boolean = false, + isSelected: Boolean = false, + tagColor: MoonLabelColors? = null, + position: MoonBundlePosition = MoonBundlePosition.Default, + maxLinesTitle: Int = 1, + onClick: () -> Unit = {}, +) { + MoonBundleCell(position = position) { + TextCell( + title = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MoonItemTitle( + text = title, + maxLines = maxLinesTitle, + ) + + if (standard != null) { + val color = tagColor ?: MoonLabelDefault.grey() + MoonLabel(standard, colors = color) + } + } + }, + subtitle = subtitle, + image = { + MoonCutBadgedBox( + badge = if (chainImageUrl != null) { + { MoonItemImage(image = chainImageUrl, size = 18.dp) } + } else { + null + }, + direction = BadgeDirection.EndBottom, + ) { + MoonItemImage(image = assetImageUrl, size = 44.dp) + } + }, + content = when { + isExpanded -> { + { + MoonItemIcon(painter = painterResource(UIKitIcon.ic_chevron_right_16)) + } + } + + isSelected -> { + { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_donemark_28), + color = UIKit.colorScheme.accent.blue, + ) + } + } + + else -> null + }, + onClick = onClick, + minHeight = 76.dp, + ) + } +} \ No newline at end of file diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/components/VerticalAssetCell.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/components/VerticalAssetCell.kt new file mode 100644 index 000000000..29ab0dd96 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/components/VerticalAssetCell.kt @@ -0,0 +1,141 @@ +package com.tonapps.deposit.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.tonapps.blockchain.contract.TokenType +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.uikit.icon.UIKitIcon +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonItemTitle +import ui.components.moon.MoonLabel +import ui.components.moon.MoonLabelColors +import ui.components.moon.MoonLabelDefault +import ui.components.moon.MoonLargeItemSubtitle +import ui.components.moon.cell.TextCell +import ui.components.moon.container.BadgeDirection +import ui.components.moon.container.MoonCutBadgedBox +import ui.painterResource +import ui.theme.UIKit + +@Composable +fun VerticalAssetCell( + currency: WalletCurrency, + isAbstract: Boolean = false, + isSelected: Boolean = false, + content: (@Composable () -> Unit)? = null, + titleContent: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + val context = LocalContext.current + VerticalAssetCell( + name = currency.code, + extendedName = if (isAbstract) null else currency.title, + standard = if (isAbstract) null else currency.tokenType?.fmt, + tagColor = currency.tokenType?.color(), + assetImageUrl = currency.iconUri?.toString(), +// chainImageUrl = remember { +// when (currency.tokenType != null) { +// true -> currency.chain.iconExternalUrl(context) +// false -> null +// } +// }, + content = content ?: { + if (isSelected) { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_donemark_thin_28), + color = UIKit.colorScheme.accent.blue, + ) + } + }, + titleContent = titleContent, + onClick = onClick, + ) +} + +@Composable +@ReadOnlyComposable +fun TokenType.color(): MoonLabelColors { + return when (this) { + TokenType.Defined.JETTON -> MoonLabelDefault.blue() + TokenType.Defined.TRC20 -> MoonLabelDefault.error() + TokenType.Defined.BEP20 -> MoonLabelDefault.orange() + else -> MoonLabelDefault.grey() + } +} + +@Composable +fun VerticalAssetCell( + name: String, + assetImageUrl: String?, + extendedName: String? = null, + chainImageUrl: String? = null, + standard: String? = null, + content: (@Composable () -> Unit)? = null, + titleContent: (@Composable () -> Unit)? = null, + tagColor: MoonLabelColors? = null, + onClick: (() -> Unit)? = null, +) { + TextCell( + title = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MoonItemTitle(text = name) + + if (standard != null) { + val color = tagColor ?: MoonLabelDefault.grey() + MoonLabel(standard, colors = color) + } + + if (extendedName != null) { + MoonLargeItemSubtitle(text = extendedName) + } + + titleContent?.invoke() + } + }, + image = { + if (chainImageUrl != null) { + MoonCutBadgedBox( + badge = { MoonItemImage(image = chainImageUrl, size = 14.dp) }, + direction = BadgeDirection.EndBottom, + ) { + MoonItemImage(image = chainImageUrl, size = 28.dp) + } + } else { + MoonItemImage(image = assetImageUrl, size = 28.dp) + } + }, + content = content, + onClick = onClick, + minHeight = 52.dp, + ) +} + + + +@Composable +fun VerticalItemCell( + title: String, + image: @Composable () -> Unit, + tags: (@Composable RowScope.() -> Unit)? = null, + content: @Composable () -> Unit, + onClick: () -> Unit = {}, +) { + TextCell( + title = title, + image = image, + tags = tags, + content = content, + onClick = onClick, + minHeight = 52.dp, + ) +} \ No newline at end of file diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/data/ExchangeRepository.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/data/ExchangeRepository.kt new file mode 100644 index 000000000..b357cd0ee --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/data/ExchangeRepository.kt @@ -0,0 +1,153 @@ +package com.tonapps.deposit.data + +import com.tonapps.async.Async +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.core.helper.EnvironmentHelper +import com.tonapps.deposit.screens.ramp.RampType +import com.tonapps.extensions.CacheKey +import com.tonapps.extensions.TimedCacheMemory +import com.tonapps.wallet.api.API +import io.exchangeapi.infrastructure.ApiResult +import io.exchangeapi.infrastructure.extractApiErrorMessage +import io.exchangeapi.models.CreateExchangeRequest +import io.exchangeapi.models.CreateP2PSessionRequest +import io.exchangeapi.models.ExchangeCalculateRequest +import io.exchangeapi.models.ExchangeCalculation +import io.exchangeapi.models.ExchangeFlow +import io.exchangeapi.models.ExchangeLayout +import io.exchangeapi.models.ExchangeMerchantInfo +import io.exchangeapi.models.ExchangeResult +import io.exchangeapi.models.P2PSessionResult +import io.exchangeapi.models.Platform +import kotlinx.coroutines.withContext +import java.util.Locale + +class ExchangeRepository( + private val api: API, + private val environment: EnvironmentHelper, +) { + sealed interface Keys : CacheKey { + data object LayoutOn : Keys + data object LayoutOff : Keys + data object Merchants : Keys + data object Currencies : Keys + } + + private val cache = TimedCacheMemory() + + suspend fun clearRampCache(rampType: RampType) { + cache.remove( + key = when (rampType) { + RampType.RampOff -> Keys.LayoutOff + RampType.RampOn -> Keys.LayoutOn + } + ) + } + + suspend fun getLayout(rampType: RampType): ExchangeLayout { + val key = when (rampType) { + RampType.RampOn -> Keys.LayoutOn + RampType.RampOff -> Keys.LayoutOff + } + return cache.getOrLoad(key) { + withContext(Async.Io) { + getLayoutCurrency(rampType, null) + } + } + } + + + suspend fun getLayoutCurrency(rampType: RampType, currency: WalletCurrency?): ExchangeLayout { + return withContext(Async.Io) { + api.exchange.exchange.getExchangeLayout( + flow = when (rampType) { + RampType.RampOn -> ExchangeFlow.deposit + RampType.RampOff -> ExchangeFlow.withdraw + }, + storeCountryCode = environment.storeCountry(), + deviceCountryCode = environment.deviceCountry(), + simCountry = environment.simCountry(), + timezone = environment.timezone(), + isVpnActive = environment.isVpnActive(), + platform = Platform.android, + currency = currency?.code, + lang = environment.locale(), + ) + } + } + + suspend fun getCurrencies(network: TonNetwork, locale: Locale): List { + return cache.getOrLoad(Keys.Currencies) { + val result = api.getCurrencies(network, locale) + buildList { + for (i in 0 until result.length()) { + val item = result.getJSONObject(i) + val code = item.getString("code") + val type = item.getString("type") + if (type.equals("fiat", ignoreCase = true)) { + add(WalletCurrency.fiat(code)) + } + } + } + .let { WalletCurrency.sort(it) } + } + } + + suspend fun getMerchants(): List { + return cache.getOrLoad(Keys.Merchants) { + withContext(Async.Io) { + api.exchange.exchange.getExchangeMerchants( + storeCountryCode = environment.storeCountry(), + deviceCountryCode = environment.deviceCountry(), + simCountry = environment.simCountry(), + timezone = environment.timezone(), + isVpnActive = environment.isVpnActive(), + platform = Platform.android, + lang = environment.locale(), + ) + } + } + } + + suspend fun calculate(request: ExchangeCalculateRequest): ExchangeCalculation { + return withContext(Async.Io) { + api.exchange.exchange.exchangeCalculate( + storeCountryCode = environment.storeCountry(), + deviceCountryCode = environment.deviceCountry(), + simCountry = environment.simCountry(), + timezone = environment.timezone(), + isVpnActive = environment.isVpnActive(), + exchangeCalculateRequest = request, + platform = Platform.android, + ) + } + } + + suspend fun createExchange(request: CreateExchangeRequest): ApiResult { + return withContext(Async.Io) { + try { + val result = api.exchange.exchange.createExchange( + storeCountryCode = environment.storeCountry(), + deviceCountryCode = environment.deviceCountry(), + simCountry = environment.simCountry(), + timezone = environment.timezone(), + isVpnActive = environment.isVpnActive(), + createExchangeRequest = request, + platform = Platform.android, + ) + ApiResult.Success(result) + } catch (e: Throwable) { + ApiResult.Error(extractApiErrorMessage(e)) + } + } + } + + suspend fun createP2PSession(request: CreateP2PSessionRequest): P2PSessionResult { + return withContext(Async.Io) { + api.exchange.p2p.createP2PSession( + createP2PSessionRequest = request, + ) + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/data/OnRampExtensions.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/data/OnRampExtensions.kt new file mode 100644 index 000000000..abeb702c1 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/data/OnRampExtensions.kt @@ -0,0 +1,196 @@ +package com.tonapps.deposit.data + +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.deposit.screens.network.CryptoNetworkInfo +import io.exchangeapi.models.ExchangeLayout +import io.exchangeapi.models.ExchangeLayoutAsset +import io.exchangeapi.models.ExchangeLayoutCryptoMethod +import io.exchangeapi.models.ExchangeLayoutItemType + + +/** + * Flattens all assets from all layout items into a single list. + */ +val ExchangeLayout.allAssets: List + get() = items.flatMap { it.assets.orEmpty() } + +/** + * Returns assets from items of the given type only. + */ +fun ExchangeLayout.assetsOfType(type: ExchangeLayoutItemType): List = + items.filter { it.type == type }.flatMap { it.assets.orEmpty() } + +fun ExchangeLayoutAsset.toWalletCurrency(): WalletCurrency { + return buildWalletCurrency(symbol, networkName, network, address, decimals, image, stablecoin) +} + +fun ExchangeLayoutCryptoMethod.toWalletCurrency(): WalletCurrency { + return buildWalletCurrency(symbol, networkName, network,"", decimals, image, stablecoin) +} + +fun ExchangeLayoutCryptoMethod.toCryptoNetworkInfo(): CryptoNetworkInfo { + return CryptoNetworkInfo( + currency = toWalletCurrency(), + networkImage = networkImage, + fee = fee, + minAmount = minAmount, + providerMinAmount = providers + .mapNotNull { it.limits?.min?.toBigDecimalOrNull() } + .filter { it > java.math.BigDecimal.ZERO } + .minOrNull() + ?.toPlainString(), + providerMaxAmount = providers + .mapNotNull { it.limits?.max?.toBigDecimalOrNull() } + .filter { it > java.math.BigDecimal.ZERO } + .maxOrNull() + ?.toPlainString(), + ) +} + +private fun buildWalletCurrency( + symbol: String, + networkName: String, + network: String, + address: String?, + decimals: Int, + imageUrl: String, + isStablecoin: Boolean, +): WalletCurrency { + val chain = networkToChain(network, address, decimals) + return WalletCurrency( + code = symbol, + title = networkName, + chain = chain, + iconUrl = imageUrl, + isToken = network != "NATIVE", + isStablecoin = isStablecoin, + ) +} + +private fun networkToChain(network: String, address: String?, decimals: Int): WalletCurrency.Chain { + return when (network.lowercase()) { + "native", "jetton" -> if (address != null) WalletCurrency.Chain.TON( + address, + decimals + ) else WalletCurrency.Chain.TON(decimals = decimals) + + "trc-20" -> if (address != null) WalletCurrency.Chain.TRON( + address, + decimals + ) else WalletCurrency.Chain.TRON(decimals = decimals) + + "erc-20" -> if (address != null) WalletCurrency.Chain.ETHEREUM( + address, + decimals + ) else WalletCurrency.Chain.ETHEREUM(decimals = decimals) + + "bep-20" -> if (address != null) WalletCurrency.Chain.BNB( + address, + decimals + ) else WalletCurrency.Chain.BNB(decimals = decimals) + + "spl" -> if (address != null) WalletCurrency.Chain.Solana( + address, + decimals + ) else WalletCurrency.Chain.Solana(decimals = decimals) + + else -> WalletCurrency.Chain.Unknown( + type = network, + address = address ?: network, + decimals = decimals + ) + } +} + +fun ExchangeLayout.cryptoAssets(): List { + return (assetsOfType(ExchangeLayoutItemType.crypto) + assetsOfType(ExchangeLayoutItemType.stablecoin)) + .map { it.toWalletCurrency() } +} + +fun ExchangeLayout.pairedCryptoAssets(symbol: String, network: String?): List { + val cryptoAssets = assetsOfType(ExchangeLayoutItemType.crypto) + + assetsOfType(ExchangeLayoutItemType.stablecoin) + val asset = cryptoAssets.find { + it.symbol.equals(symbol, ignoreCase = true) + && it.network.equals(network, ignoreCase = true) + } ?: return emptyList() + + return asset.cryptoMethods + .map { it.toWalletCurrency() } +} + +fun ExchangeLayout.pairedCryptoNetworkInfos(symbol: String, network: String?): List { + val cryptoAssets = assetsOfType(ExchangeLayoutItemType.crypto) + + assetsOfType(ExchangeLayoutItemType.stablecoin) + val asset = cryptoAssets.find { + it.symbol.equals(symbol, ignoreCase = true) + && it.network.equals(network, ignoreCase = true) + } ?: return emptyList() + + return asset.cryptoMethods + .map { it.toCryptoNetworkInfo() } +} + +fun ExchangeLayout.resolveAssetFromDeeplink( + token: String?, + network: String?, +): WalletCurrency? { + if (token == null) return null + + // Try by symbol + network + val bySymbol = allAssets.find { + it.symbol.equals(token, ignoreCase = true) && + (network == null || it.network.equals(network, ignoreCase = true)) + } + if (bySymbol != null) return bySymbol.toWalletCurrency() + + // Fallback to well-known tokens + return resolveWellKnownToken(token, network) +} + +private fun resolveWellKnownToken(token: String, network: String?): WalletCurrency? { + return when { + token.equals("TON", ignoreCase = true) -> WalletCurrency.TON + token.equals("USDT", ignoreCase = true) -> when { + network?.contains("trc", ignoreCase = true) == true || + network?.contains("tron", ignoreCase = true) == true -> WalletCurrency.USDT_TRON + + else -> WalletCurrency.USDT_TON + } + + else -> null + } +} + +fun ExchangeLayout.cashAssets(): List { + return assetsOfType(ExchangeLayoutItemType.fiat) + .map { it.toWalletCurrency() } +} + +fun ExchangeLayout.stablecoinAssets(): List { + return assetsOfType(ExchangeLayoutItemType.stablecoin) + .flatMap { it.cryptoMethods } + .distinctBy { it.symbol.uppercase() } + .map { it.toWalletCurrency() } +} + +fun ExchangeLayout.stablecoinRootAssets(): List { + return assetsOfType(ExchangeLayoutItemType.stablecoin) + .map { it.toWalletCurrency() } +} + + +fun ExchangeLayout.resolveAssets(filter: AssetFilter = AssetFilter.All): List { + return when (filter) { + AssetFilter.All -> cryptoAssets() + AssetFilter.Cash -> cashAssets() + AssetFilter.Stablecoin -> stablecoinAssets() + AssetFilter.StablecoinRoot -> stablecoinRootAssets() + } +} + +@kotlinx.serialization.Serializable +enum class AssetFilter { + All, Cash, Stablecoin, StablecoinRoot +} + diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/depositRouter.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/depositRouter.kt new file mode 100644 index 000000000..5c2e2bd4d --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/depositRouter.kt @@ -0,0 +1,326 @@ +package com.tonapps.deposit + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.res.stringResource +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events.DepositFlow.DepositFlowBuyAsset +import com.tonapps.bus.generated.Events.DepositFlow.DepositFlowFrom +import com.tonapps.core.navigation.LocalResultStore +import com.tonapps.core.navigation.rememberResultStore +import com.tonapps.deposit.data.AssetFilter +import com.tonapps.deposit.screens.assets.AssetsCryptoExtendedFeature +import com.tonapps.deposit.screens.assets.AssetsCryptoExtendedFeatureData +import com.tonapps.deposit.screens.assets.AssetsExtendedScreen +import com.tonapps.deposit.screens.buy.crypto.BuyWithCryptoFeature +import com.tonapps.deposit.screens.buy.crypto.BuyWithCryptoScreen +import com.tonapps.deposit.screens.currency.SelectCurrencyFeature +import com.tonapps.deposit.screens.currency.SelectCurrencyScreen +import com.tonapps.deposit.screens.method.KEY_CURRENCY_SELECTION_RESULT +import com.tonapps.deposit.screens.method.PaymentMethodFeature +import com.tonapps.deposit.screens.method.PaymentMethodFeatureData +import com.tonapps.deposit.screens.method.PaymentMethodScreen +import com.tonapps.deposit.screens.method.PaymentMethodSectionFilter +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.screens.network.SelectNetworkData +import com.tonapps.deposit.screens.network.SelectNetworkFeature +import com.tonapps.deposit.screens.network.SelectNetworkScreen +import com.tonapps.deposit.screens.qr.QrAssetData +import com.tonapps.deposit.screens.qr.QrAssetFeature +import com.tonapps.deposit.screens.qr.QrScreen +import com.tonapps.deposit.screens.ramp.RampFeature +import com.tonapps.deposit.screens.ramp.RampScreen +import com.tonapps.deposit.screens.ramp.RampType +import com.tonapps.deposit.screens.ramp.amount.DepositAmountFeature +import com.tonapps.deposit.screens.ramp.amount.DepositAmountScreen +import com.tonapps.deposit.screens.ramp.amount.RampAmountData +import com.tonapps.wallet.localization.Localization +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import ui.moon.MoonNav + +internal fun WalletCurrency.toBuyAsset(): DepositFlowBuyAsset { + return when (address) { + WalletCurrency.USDT_TON.address -> DepositFlowBuyAsset.TonJettonUSDT + WalletCurrency.USDT_TRON.address -> DepositFlowBuyAsset.TronTrc20USDT + else -> DepositFlowBuyAsset.TonNativeTON + } +} + +@Serializable +sealed interface DepositRoutes : NavKey { + @Serializable + data object Ramp : DepositRoutes + + @Serializable + data class ExtendedCryptoList(val filter: AssetFilter = AssetFilter.All, val preferredCurrency: String? = null) : DepositRoutes + + @Serializable + data class Buy( + val asset: RampAsset? = null, + val ft: String? = null, + val tn: String? = null, + val tt: String? = null, + val fn: String? = null, + val cm: String? = null, + val sectionFilter: PaymentMethodSectionFilter = PaymentMethodSectionFilter.All, + val preferredCurrency: String? = null, + ) : DepositRoutes + + @Serializable + data class SelectNetwork(val to: RampAsset, val stablecoinCode: String, val selectedSymbol: String? = null) : DepositRoutes + + @Serializable + data object Qr : DepositRoutes + + @Serializable + data class BuyWithCrypto( + val from: WalletCurrency, + val to: RampAsset, + ) : DepositRoutes + + @Serializable + data class SelectCurrency(val selectedCode: String? = null) : DepositRoutes + + @Serializable + data class Amount( + val asset: RampAsset, + val paymentMethodType: String, + val fiatCurrency: WalletCurrency + ) : DepositRoutes + +} + +@Composable +fun DepositRouter( + initial: DepositRoutes = DepositRoutes.Ramp, + onBack: () -> Unit, + openProvider: (String) -> Unit, +) { + val backStack = rememberNavBackStack(initial) + val resultStore = rememberResultStore() + val popBackStack = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + onBack() + } + } + + CompositionLocalProvider(LocalResultStore provides resultStore) { + MoonNav( + backStack = backStack, + ) { key -> + when (key) { + is DepositRoutes.Ramp -> NavEntry(key) { + val viewModel = koinViewModel { parametersOf(RampType.RampOn) } + RampScreen( + feature = viewModel, + rampType = RampType.RampOn, + onClose = onBack, + onQr = { + AnalyticsHelper.Default.events.depositFlow.depositClickReceiveTokens(from = DepositFlowFrom.WalletScreen) + backStack.add(DepositRoutes.Qr) + }, + onSend = { }, + onBuyCash = { preferredCurrency -> + backStack.add(DepositRoutes.ExtendedCryptoList(filter = AssetFilter.Cash, preferredCurrency = preferredCurrency)) + }, + onBuyCrypto = { asset -> + backStack.add(DepositRoutes.Buy( + asset = asset, + sectionFilter = PaymentMethodSectionFilter.CryptoOnly, + )) + }, + onBuyStablecoins = { + backStack.add(DepositRoutes.ExtendedCryptoList(filter = AssetFilter.StablecoinRoot)) + }, + ) + } + + is DepositRoutes.ExtendedCryptoList -> NavEntry(key) { + val viewModel = koinViewModel { parametersOf(AssetsCryptoExtendedFeatureData(RampType.RampOn, key.filter)) } + + val sectionFilter = when (key.filter) { + AssetFilter.Cash -> PaymentMethodSectionFilter.CashOnly + AssetFilter.Stablecoin, AssetFilter.StablecoinRoot -> PaymentMethodSectionFilter.StablecoinOnly + AssetFilter.All -> PaymentMethodSectionFilter.All + } + + AssetsExtendedScreen( + feature = viewModel, + title = stringResource(Localization.choose_asset), + onClose = onBack, + onBack = { popBackStack() }, + onSelected = { currency -> + when (key.filter) { + AssetFilter.StablecoinRoot -> { + AnalyticsHelper.Default.events.depositFlow.depositClickBuy( + buyAsset = currency.toBuyAsset() + ) + backStack.add(DepositRoutes.Buy( + asset = RampAsset.Currency(currency), + sectionFilter = PaymentMethodSectionFilter.StablecoinOnly, + )) + } + AssetFilter.Stablecoin -> { + val rootAsset = RampAsset.Currency(currency) + backStack.add(DepositRoutes.SelectNetwork( + to = rootAsset, + stablecoinCode = rootAsset.currencyCode, + selectedSymbol = currency.code, + )) + } + else -> { + AnalyticsHelper.Default.events.depositFlow.depositClickBuy( + buyAsset = currency.toBuyAsset() + ) + backStack.add(DepositRoutes.Buy( + asset = RampAsset.Currency(currency), + sectionFilter = sectionFilter, + preferredCurrency = key.preferredCurrency, + )) + } + } + }, + ) + } + + is DepositRoutes.Buy -> NavEntry(key) { + val viewModel = koinViewModel { + parametersOf(PaymentMethodFeatureData(key.asset, RampType.RampOn, key.ft, key.tn, key.tt, key.fn, key.cm, key.sectionFilter, key.preferredCurrency)) + } + val currencyResult = resultStore.removeResult(KEY_CURRENCY_SELECTION_RESULT) + + PaymentMethodScreen( + feature = viewModel, + fallbackAsset = key.asset, + currencySelectionResult = currencyResult, + onClose = onBack, + onBack = { popBackStack() }, + onPaymentMethodClick = { asset, paymentMethodType, fiatCurrency -> + backStack.add( + DepositRoutes.Amount( + asset = asset, + paymentMethodType = paymentMethodType, + fiatCurrency = fiatCurrency + ) + ) + }, + onBuyWithCrypto = { asset, currency -> + backStack.add(DepositRoutes.BuyWithCrypto(from = currency, to = asset)) + }, + onSelectCurrency = { selectedCode -> + backStack.add(DepositRoutes.SelectCurrency(selectedCode = selectedCode)) + }, + onSelectNetwork = { asset, selectedSymbol, _ -> + backStack.add(DepositRoutes.SelectNetwork( + to = asset, + stablecoinCode = asset.currencyCode, + selectedSymbol = selectedSymbol, + )) + }, + onOpenP2P = openProvider, + onAutoNavigateCrypto = { resolvedAsset, targetCurrency -> + backStack.add(DepositRoutes.BuyWithCrypto(from = targetCurrency, to = resolvedAsset)) + }, + onAutoNavigateCash = { resolvedAsset, paymentMethodType, fiatCurrency -> + if (paymentMethodType == "p2p") { // TODO handle + return@PaymentMethodScreen + } + + backStack.add( + DepositRoutes.Amount( + asset = resolvedAsset, + paymentMethodType = paymentMethodType, + fiatCurrency = fiatCurrency, + ) + ) + }, + ) + } + + is DepositRoutes.SelectNetwork -> NavEntry(key) { + val feature = koinViewModel { + parametersOf(SelectNetworkData( + stablecoinCode = key.stablecoinCode, + stablecoinNetwork = key.to.network, + rampType = RampType.RampOn, + selectedSymbol = key.selectedSymbol, + )) + } + SelectNetworkScreen( + feature = feature, + onSelect = { networkInfo -> + backStack.add(DepositRoutes.BuyWithCrypto(from = networkInfo.currency, to = key.to)) + }, + onClose = onBack, + onBack = { popBackStack() }, + ) + } + + is DepositRoutes.Qr -> NavEntry(key) { + val viewModel = koinViewModel { + parametersOf(QrAssetData()) + } + + QrScreen( + viewModel = viewModel, + showBuyButton = false, // TODO + onFinishClick = { popBackStack() }, + onBuyClick = {}, // TODO + ) + } + + is DepositRoutes.BuyWithCrypto -> NavEntry(key) { + val viewModel = koinViewModel { parametersOf(key.from, key.to) } + BuyWithCryptoScreen( + viewModel = viewModel, + from = key.from, + to = key.to, + onClose = onBack, + onBack = { popBackStack() }, + ) + } + + is DepositRoutes.SelectCurrency -> NavEntry(key) { + val feature = koinViewModel() + SelectCurrencyScreen( + feature = feature, + selectedCurrencyCode = key.selectedCode, + onConfirm = { currency -> + resultStore.setResult(KEY_CURRENCY_SELECTION_RESULT, currency) + }, + onBack = { popBackStack() }, + onClose = onBack, + ) + } + + is DepositRoutes.Amount -> NavEntry(key) { + val amountData = RampAmountData( + assetFrom = RampAsset.Currency(key.fiatCurrency), + assetTo = key.asset, + paymentMethodType = key.paymentMethodType, + ) + val viewModel = koinViewModel { + parametersOf(amountData) + } + + DepositAmountScreen( + feature = viewModel, + onClose = onBack, + onBack = { popBackStack() }, + onContinue = openProvider, + ) + } + + else -> throw IllegalStateException("Unknown key: $key") + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/assets/AssetsExtendedFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/assets/AssetsExtendedFeature.kt new file mode 100644 index 000000000..c16829fe1 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/assets/AssetsExtendedFeature.kt @@ -0,0 +1,119 @@ +package com.tonapps.deposit.screens.assets + +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.deposit.data.AssetFilter +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.deposit.data.resolveAssets +import com.tonapps.deposit.screens.ramp.RampType +import com.tonapps.mvi.MviFeature +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.mvi.MviSubject +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.runBlocking + +sealed interface DepositCryptoExtendedAction : MviAction { + data object Init : DepositCryptoExtendedAction +} + +sealed interface DepositCryptoExtendedState : MviState { + data class Data( + val allAssets: List = emptyList(), + val otherAssets: List = emptyList(), + ) : DepositCryptoExtendedState +} + +class DepositCryptoExtendedViewState( + val global: MviProperty +) : MviViewState + +data class AssetsCryptoExtendedFeatureData( + val rampType: RampType, + val filter: AssetFilter = AssetFilter.All, +) + +private val tronUnsupportedWalletTypes = setOf( + WalletType.Signer, + WalletType.Lockup, + WalletType.Ledger, + WalletType.SignerQR, + WalletType.Keystone, +) + +class AssetsCryptoExtendedFeature( + private val data: AssetsCryptoExtendedFeatureData, + private val onRampRepository: ExchangeRepository, + private val accountRepository: AccountRepository, +) : MviFeature( + initState = runBlocking { + try { + val walletType = accountRepository.getSelectedWallet()?.type + val hideTron = walletType in tronUnsupportedWalletTypes + onRampRepository.getLayout(data.rampType).resolveAssets(data.filter) + .filterNot { hideTron && it.isTronChain } + } catch (_: Throwable) { + emptyList() + } + }.let { assets -> + DepositCryptoExtendedState.Data(allAssets = assets, otherAssets = assets) + }, + initAction = DepositCryptoExtendedAction.Init +) { + + private val searchQuery = MviSubject() + + init { + searchQuery.events + .debounce(300) + .mapLatest { query -> filterAssets(query) } + .launchIn(bgScope) + } + + fun setSearchQuery(query: String) { + searchQuery.emit(query) + } + + override fun createViewState(): DepositCryptoExtendedViewState { + return buildViewState { + DepositCryptoExtendedViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: DepositCryptoExtendedAction) { + when (action) { + is DepositCryptoExtendedAction.Init -> loadAssets() + } + } + + private suspend fun loadAssets() { + try { + val walletType = accountRepository.getSelectedWallet()?.type + val hideTron = walletType in tronUnsupportedWalletTypes + val layout = onRampRepository.getLayout(data.rampType) + val assets = layout.resolveAssets(data.filter) + .filterNot { hideTron && it.isTronChain } + setState { + DepositCryptoExtendedState.Data( + allAssets = assets, + otherAssets = assets, + ) + } + } catch (_: Throwable) { + // keep initial state + } + } + + private fun filterAssets(query: String) { + setState { + val trimmed = query.trim() + val filtered = if (trimmed.isEmpty()) allAssets else allAssets.filter { it.containsQuery(trimmed) } + copy(otherAssets = filtered) + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/assets/AssetsExtendedScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/assets/AssetsExtendedScreen.kt new file mode 100644 index 000000000..f033f6a12 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/assets/AssetsExtendedScreen.kt @@ -0,0 +1,117 @@ +package com.tonapps.deposit.screens.assets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.deposit.components.VerticalAssetCell +import com.tonapps.mvi.props.observeSafeState +import ui.components.moon.MoonItemDivider +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonSearchCell +import ui.components.moon.container.MoonScaffold +import ui.preview.ThemedPreview +import ui.workaround.hideKeyboardOnScrollConnection + +@Composable +fun AssetsExtendedScreen( + feature: AssetsCryptoExtendedFeature, + title: String = "Crypto", + onClose: () -> Unit, + onBack: () -> Unit, + onSelected: (WalletCurrency) -> Unit, +) { + val state by feature.state.global.observeSafeState() + Content( + state = state, + title = title, + onSearchQuery = feature::setSearchQuery, + onClose = onClose, + onBack = onBack, + onSelected = onSelected + ) +} + +@Composable +private fun Content( + state: DepositCryptoExtendedState, + title: String, + onSearchQuery: (String) -> Unit, + onClose: () -> Unit, + onBack: () -> Unit, + onSelected: (WalletCurrency) -> Unit, +) { + val searchText = remember { mutableStateOf("") } + + MoonScaffold( + title = title, + onClose = onClose, + onBack = onBack, + ) { + MoonSearchCell( + searchText = searchText, + onChanged = { + searchText.value = it + onSearchQuery(it) + }, + error = false, + ) + Spacer(Modifier.height(8.dp)) + + when (val state = state) { + is DepositCryptoExtendedState.Data -> { + val otherAssets = state.otherAssets + if (otherAssets.isNotEmpty()) { + LazyColumn( + modifier = Modifier.nestedScroll(hideKeyboardOnScrollConnection()), + contentPadding = remember { PaddingValues(bottom = 32.dp) } + ) { + item { + MoonBundleCell { + Column { + otherAssets.forEachIndexed { index, asset -> + if (index > 0) MoonItemDivider() + VerticalAssetCell(asset) { onSelected(asset) } + } + } + } + } + } + } + } + } + } +} + +@Preview +@Composable +private fun ContentPreview() { + ThemedPreview { + Content( + state = DepositCryptoExtendedState.Data( + allAssets = listOf(), + otherAssets = listOf( + WalletCurrency.TON, + WalletCurrency.TON, + WalletCurrency.TON, + WalletCurrency.USD, + ) + ), + title = "Title", + onSearchQuery = {}, + onClose = {}, + onBack = {}, + onSelected = {}, + ) + } +} \ No newline at end of file diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/buy/crypto/BuyWithCryptoFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/buy/crypto/BuyWithCryptoFeature.kt new file mode 100644 index 000000000..7557a6475 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/buy/crypto/BuyWithCryptoFeature.kt @@ -0,0 +1,151 @@ +package com.tonapps.deposit.screens.buy.crypto + +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.toBuyAsset +import com.tonapps.icu.Coins +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.data.account.AccountRepository +import io.exchangeapi.infrastructure.ApiResult +import io.exchangeapi.models.CreateExchangeRequest +import io.exchangeapi.models.ExchangeFlow + +sealed interface BuyWithCryptoAction : MviAction { + data object Init : BuyWithCryptoAction +} + +sealed interface BuyWithCryptoState : MviState { + data object Loading : BuyWithCryptoState + + data class Data( + val payinAddress: String, + val amountExpectedFrom: String, + val rate: String?, + val fromCode: String, + val toCode: String, + val minDeposit: String?, + val maxDeposit: String?, + val network: String?, + val memoTitle: String?, + val memoValue: String?, + val estimatedDurationSeconds: Int?, + ) : BuyWithCryptoState + + data class Error(val message: String?) : BuyWithCryptoState +} + +class BuyWithCryptoViewState( + val global: MviProperty +) : MviViewState + +class BuyWithCryptoFeature( + val from: WalletCurrency, + val to: RampAsset, + private val onRampRepository: ExchangeRepository, + private val accountRepository: AccountRepository, +) : MviFeature( + initState = BuyWithCryptoState.Loading, + initAction = BuyWithCryptoAction.Init +) { + + init { + AnalyticsHelper.Default.events.depositFlow.depositViewC2c( + buyAsset = to.toCurrency.toBuyAsset(), + sellAsset = from.code + ) + } + + override fun createViewState(): BuyWithCryptoViewState { + return buildViewState { + BuyWithCryptoViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: BuyWithCryptoAction) { + when (action) { + is BuyWithCryptoAction.Init -> loadData() + } + } + + private suspend fun loadData() { + try { + val wallet = accountRepository.getSelectedWallet()!! + val walletAddress = resolveWalletAddress(wallet) + + val networkDisplayName = from.title + + val apiResult = onRampRepository.createExchange( + CreateExchangeRequest( + from = from.code, + to = to.currencyCode, + wallet = walletAddress, + fromNetwork = from.network, + toNetwork = to.network, + flow = ExchangeFlow.deposit, + ) + ) + + when (apiResult) { + is ApiResult.Success -> { + val result = apiResult.data + setState { + BuyWithCryptoState.Data( + payinAddress = result.payinAddress, + amountExpectedFrom = result.amountExpectedFrom, + rate = formatRate(from.code, to.currencyCode, result.rate), + fromCode = from.code, + toCode = to.currencyCode, + minDeposit = result.minDeposit, + maxDeposit = result.maxDeposit, + network = networkDisplayName, + memoTitle = result.extraIdName, + memoValue = result.payinExtraId, + estimatedDurationSeconds = result.estimatedDuration, + ) + } + } + is ApiResult.Error -> { + setState { BuyWithCryptoState.Error(apiResult.message) } + } + } + } catch (e: Throwable) { + L.e(e) + setState { BuyWithCryptoState.Error(e.message) } + } + } + + private suspend fun resolveWalletAddress(wallet: WalletEntity): String { + val toChain = (to as? RampAsset.Currency)?.currency?.chain + return when (toChain) { + is WalletCurrency.Chain.TRON -> { + accountRepository.getTronAddress(wallet.id) ?: wallet.address + } + + else -> wallet.address + } + } + + // TODO duplication + private fun formatRate( + fromCode: String, + toCode: String, + receiveAmountStr: String, + ): String? { + val receiveAmount = runCatching { Coins.of(receiveAmountStr) } + .getOrNull() + ?: return null + + val fromFormat = CurrencyFormatter.format(fromCode, Coins.ONE, replaceSymbol = false) + val rateFormat = CurrencyFormatter.format(toCode, receiveAmount, replaceSymbol = false) + return "$fromFormat ≈ $rateFormat" + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/buy/crypto/BuyWithCryptoScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/buy/crypto/BuyWithCryptoScreen.kt new file mode 100644 index 000000000..a9a9af16c --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/buy/crypto/BuyWithCryptoScreen.kt @@ -0,0 +1,541 @@ +package com.tonapps.deposit.screens.buy.crypto + +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.core.helper.ClipboardManager +import com.tonapps.core.helper.rememberClipboardManager +import com.tonapps.core.helper.rememberShareManager +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.screens.qr.QrContent +import com.tonapps.deposit.utils.formatDuration +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Links +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonAccentButton +import ui.components.moon.MoonActionIcon +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonLabel +import ui.components.moon.MoonLabelDefault +import ui.components.moon.MoonTopAppBarSimple +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.cell.MoonButtonCellDefaults +import ui.components.moon.cell.MoonDescriptionCell +import ui.components.moon.cell.MoonInfoCell +import ui.components.moon.cell.MoonPropertyCell +import ui.components.moon.cell.MoonTextContentCell +import ui.components.moon.container.MoonCutRow +import ui.components.moon.container.MoonScaffold +import ui.components.moon.container.MoonSurface +import ui.painterResource +import ui.text.toAnnotatedString +import ui.text.withLink +import ui.theme.LocalAppColorScheme +import ui.theme.UIKit +import ui.theme.modifiers.simmerOn +import ui.utils.toRichSpanStyle + +@Composable +fun BuyWithCryptoScreen( + viewModel: BuyWithCryptoFeature, + from: WalletCurrency, + to: RampAsset, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val state by viewModel.state.global.observeSafeState() + + BuyWithCryptoContent( + state = state, + from = from, + to = to, + onBack = onBack, + onClose = onClose, + ) +} + +@Composable +private fun BuyWithCryptoContent( + state: BuyWithCryptoState, + from: WalletCurrency, + to: RampAsset, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val data = state as? BuyWithCryptoState.Data + + val payinAddress = data?.payinAddress ?: "" + val payinAddressAnnotated = remember(payinAddress) { payinAddress.toAnnotatedString() } + + var isQrShown by remember { mutableStateOf(false) } + val clipboard = rememberClipboardManager() + + if (isQrShown && payinAddress.isNotEmpty()) { + QrDialog( + address = payinAddress, + tokenImage = from.iconUri ?: Uri.EMPTY, + onClose = { isQrShown = false }, + ) + } + + MoonScaffold( + Modifier.fillMaxSize() + .navigationBarsPadding(), + title = stringResource(Localization.deposit_send, from.code), + onClose = onClose, + onBack = onBack, + ) { + Box(Modifier.fillMaxSize()) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .nestedScroll(rememberNestedScrollInteropConnection()) + .padding(bottom = 72.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val color = UIKit.colorScheme.accent.orange + val prefix = stringResource(Localization.deposit_address_warning_prefix) + val highlight = stringResource(Localization.deposit_address_warning_highlight) + val suffix = stringResource(Localization.deposit_address_warning_suffix) + val text = remember(prefix, highlight, suffix) { + buildAnnotatedString { + append(prefix) + withStyle(SpanStyle(color)) { + append(highlight) + } + append(suffix) + } + } + + MoonInfoCell(text = text, maxLines = 4) + + Spacer(Modifier.height(32.dp)) + + ExchangeCell( + from = from, + to = to.toCurrency, + rate = data?.rate, + ) + + Spacer(Modifier.height(16.dp)) + + MoonBundleCell( + modifier = Modifier.simmerOn(data == null), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (data?.memoTitle != null && data.memoValue != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(Localization.deposit_address, from.code), + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.secondary, + ) + + Spacer(Modifier.height(2.dp)) + + val displayAddress = remember(payinAddress) { + formatAddress(payinAddress) + } + + Text( + text = displayAddress, + style = UIKit.typography.mono, + color = UIKit.colorScheme.text.primary, + textAlign = TextAlign.Center, + maxLines = 3, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MoonActionIcon( + painter = painterResource(id = UIKitIcon.ic_qr_code_16), + onClick = { isQrShown = true }, + size = 28.dp, + tintColor = UIKit.colorScheme.icon.secondary, + contentDescription = "Navigation" + ) + + MoonActionIcon( + painter = painterResource(id = UIKitIcon.ic_copy_16), + onClick = { clipboard.copy(payinAddressAnnotated.toString()) }, + size = 28.dp, + tintColor = UIKit.colorScheme.icon.secondary, + contentDescription = "Navigation" + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = data.memoTitle, + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.secondary, + ) + + MoonLabel( + text = stringResource(Localization.required), + colors = MoonLabelDefault.orange(), + ) + } + + Spacer(Modifier.height(2.dp)) + + Text( + text = data.memoValue, + style = UIKit.typography.mono, + color = UIKit.colorScheme.text.primary, + textAlign = TextAlign.Center, + maxLines = 3, + ) + } + + + Row( + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MoonActionIcon( + painter = painterResource(id = UIKitIcon.ic_copy_16), + onClick = { clipboard.copy(data.memoValue) }, + size = 28.dp, + tintColor = UIKit.colorScheme.icon.secondary, + contentDescription = "Navigation" + ) + } + } + + + val copyDetailsText = buildString { + append(from.code) + append(' ') + append(from.title) + append(" address:\n") + append(payinAddress) + append("\n\n") + append(data.memoTitle) + append(":\n") + append(data.memoValue) + } + + MoonAccentButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(Localization.copy_details), + icon = { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_copy_16), + color = UIKit.colorScheme.icon.primary, + ) + } + ) { clipboard.copy(copyDetailsText) } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Localization.deposit_address, from.code), + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.secondary, + ) + + Spacer(Modifier.height(2.dp)) + + val displayAddress = remember(payinAddress) { + formatAddress(payinAddress) + } + + Text( + text = displayAddress, + style = UIKit.typography.mono, + color = UIKit.colorScheme.text.primary, + textAlign = TextAlign.Center, + maxLines = 3, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + MoonAccentButton( + modifier = Modifier.weight(1f), + text = stringResource(Localization.copy_address), + icon = { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_copy_16), + color = UIKit.colorScheme.icon.primary, + ) + } + ) { clipboard.copy(payinAddressAnnotated.toString()) } + + MoonActionIcon( + painter = painterResource(id = UIKitIcon.ic_qr_code_16), + onClick = { isQrShown = true }, + size = 48.dp, + tintColor = UIKit.colorScheme.icon.primary, + backgroundColor = UIKit.colorScheme.background.contentTint, + contentDescription = "Navigation" + ) + } + } + } + } + + Spacer(Modifier.height(8.dp)) + + MoonBundleCell( + modifier = Modifier.simmerOn(data == null, height = 56.dp), + ) { + Column { + if (data?.minDeposit != null) { + MoonPropertyCell( + title = stringResource(Localization.min_amount_label), + value = "${data.minDeposit} ${from.code}", + ) + } + + if (data?.maxDeposit != null) { + MoonPropertyCell( + title = stringResource(Localization.max_amount_label), + value = "${data.maxDeposit} ${from.code}", + ) + } + + if (data?.network != null) { + MoonPropertyCell( + title = stringResource(Localization.network), + value = data.network, + ) + } + + if (data?.estimatedDurationSeconds != null) { + MoonPropertyCell( + title = stringResource(Localization.ramp_estimated_time), + value = formatDuration(data.estimatedDurationSeconds), + ) + } + } + } + + // TODO replace + val style = UIKit.typography.body2 + val linkColor = UIKit.colorScheme.text.accent + val termsText = stringResource(Localization.terms_of_service) + val disclaimerTemplate = stringResource(Localization.deposit_changelly_disclaimer, termsText) + MoonDescriptionCell( + text = remember(disclaimerTemplate, LocalAppColorScheme.current) { + buildAnnotatedString { + val termsStart = disclaimerTemplate.indexOf(termsText) + if (termsStart >= 0) { + append(disclaimerTemplate.substring(0, termsStart)) + withLink( + text = termsText, + link = Links.ChangellyTerms, + color = linkColor, + style = style, + ) + append(disclaimerTemplate.substring(termsStart + termsText.length)) + } else { + append(disclaimerTemplate) + } + } + } + ) + } + + MoonButtonCell( + modifier = Modifier.align(Alignment.BottomCenter), + text = stringResource(Localization.deposit_go_to_main), + colors = MoonButtonCellDefaults.ButtonColorsSecondary + ) { onClose() } + } + } +} + +private fun formatAddress(address: String): String { + if (address.isEmpty()) return "" + val mid = address.length / 2 + return address.substring(0, mid) + "\n" + address.substring(mid) +} + +@Composable +private fun ExchangeCell( + from: WalletCurrency, + to: WalletCurrency, + rate: String?, +) { + val headerStyle = UIKit.typography.h2 + val headerColor = UIKit.colorScheme.text.primary + val headerSecondaryColor = UIKit.colorScheme.text.secondary + + val fromChainName = from.title + val toChainName = to.title + val sendLabel = stringResource(Localization.send_currency, from.code) + val receiveLabel = stringResource(Localization.receive_currency, to.code) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MoonCutRow { + MoonItemImage(size = 72.dp, image = from.iconUri?.toString()) + MoonItemImage(size = 72.dp, image = to.iconUri?.toString()) + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = remember(LocalAppColorScheme.current, from, sendLabel) { + buildAnnotatedString { + withStyle(headerStyle.toRichSpanStyle(color = headerColor)) { + append("$sendLabel ") + } + + withStyle(headerStyle.toRichSpanStyle(color = headerSecondaryColor)) { + append(fromChainName) + } + } + }, + textAlign = TextAlign.Center, + maxLines = 1, + ) + + Text( + text = remember(LocalAppColorScheme.current, to, receiveLabel) { + buildAnnotatedString { + withStyle(headerStyle.toRichSpanStyle(color = headerColor)) { + append("$receiveLabel ") + } + + withStyle(headerStyle.toRichSpanStyle(color = headerSecondaryColor)) { + append(toChainName) + } + } + }, + textAlign = TextAlign.Center, + maxLines = 1, + ) + + Spacer(Modifier.height(4.dp)) + + Text( + modifier = Modifier.simmerOn(rate == null, width = 56.dp), + text = rate.orEmpty(), + color = UIKit.colorScheme.text.secondary, + style = UIKit.typography.body1 + ) + } +} + +@Composable +private fun QrDialog( + address: String, + tokenImage: Uri, + onClose: () -> Unit = {}, +) { + val addressAnnotated = remember(address) { address.toAnnotatedString() } + val shareManager = rememberShareManager() + val clipboard = LocalClipboardManager.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onClose, + sheetState = sheetState, + dragHandle = null, + ) { + MoonSurface { + Column( + Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MoonTopAppBarSimple( + title = "", + navigationIconRes = UIKitIcon.ic_chevron_down_16, + onNavigationClick = onClose, + backgroundColor = Color.Transparent + ) + + MoonTextContentCell( + title = stringResource(Localization.deposit_payment_qr_code), + description = stringResource(Localization.deposit_scan_qr_description) + ) + + Spacer(Modifier.height(32.dp)) + + QrContent( + walletType = WalletType.Default, + walletAddress = address, + content = address, + tokenImage = tokenImage, + blockchainImage = null, + onCopyClick = { clipboard.setText(addressAnnotated) }, + onShareClick = { shareManager.share(address) } + ) + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/ConfirmFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/ConfirmFeature.kt new file mode 100644 index 000000000..b758dbf56 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/ConfirmFeature.kt @@ -0,0 +1,1321 @@ +package com.tonapps.deposit.screens.confirm + +import android.app.Application +import android.content.Context +import com.tonapps.blockchain.model.legacy.Amount +import com.tonapps.blockchain.model.legacy.Fee +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.TransferEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.errors.InsufficientBalanceType +import com.tonapps.blockchain.model.legacy.errors.SendBlockchainException +import com.tonapps.blockchain.ton.contract.WalletFeature +import com.tonapps.blockchain.ton.extensions.EmptyPrivateKeyEd25519 +import com.tonapps.blockchain.ton.extensions.base64 +import com.tonapps.blockchain.tron.TronTransfer +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events +import com.tonapps.bus.generated.opTerminal +import com.tonapps.core.helper.WalletRedMetadata +import com.tonapps.deposit.screens.send.SendException +import com.tonapps.deposit.screens.send.SendParams +import com.tonapps.deposit.screens.send.state.SendDestination +import com.tonapps.deposit.screens.send.state.SendFee +import com.tonapps.deposit.usecase.emulation.EmulationUseCase +import com.tonapps.deposit.usecase.emulation.InsufficientBalanceError +import com.tonapps.deposit.usecase.sign.SignUseCase +import com.tonapps.extensions.currentTimeMillis +import com.tonapps.extensions.currentTimeSecondsInt +import com.tonapps.extensions.generateUuid +import com.tonapps.extensions.getUserMessage +import com.tonapps.extensions.isPositive +import com.tonapps.icu.Coins +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.ledger.ton.getLedgerTransaction +import com.tonapps.legacy.enteties.SendMetadataEntity +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.api.API +import com.tonapps.wallet.api.tron.entity.TronResourcesEntity +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.battery.BatteryMapper +import com.tonapps.wallet.data.battery.BatteryRepository +import com.tonapps.wallet.data.rates.RatesRepository +import com.tonapps.wallet.data.settings.BatteryTransaction +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.settings.entities.PreferredFeeMethod +import com.tonapps.wallet.data.settings.entities.PreferredTronFeeMethod +import com.tonapps.wallet.data.token.TokenRepository +import com.tonapps.wallet.data.tx.TransactionManager +import com.tonapps.wallet.localization.Localization +import com.tonapps.wallet.localization.Plurals +import io.batteryapi.models.EstimatedTronTx +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import org.ton.block.AddrStd +import org.ton.cell.Cell +import java.math.BigInteger +import java.util.concurrent.CancellationException +import kotlin.math.abs + +// region MVI Contract +sealed interface ConfirmAction : MviAction { + data object Init : ConfirmAction + data class SelectFee(val fee: SendFee) : ConfirmAction + data class Sign(val context: Context) : ConfirmAction // TODO remove context + data class Retry(val context: Context) : ConfirmAction // TODO remove context +} + +sealed interface ConfirmationError { + data object Unknown : ConfirmationError + data object InsufficientBalance : ConfirmationError + data class Message(val text: String) : ConfirmationError +} + +sealed interface ConfirmState : MviState { + + data object Loading : ConfirmState + + data class Ready( + val token: TokenEntity? = null, + val walletName: String = "", + val recipientDisplay: String? = null, + val recipientAddress: String = "", + val displayCurrency: WalletCurrency? = null, + val amountFormatted: CharSequence = "", + val amountFiatFormatted: CharSequence = "", + val feeFormatted: CharSequence = "", + val feeFiatFormatted: CharSequence = "", + val comment: String? = null, + val encryptedComment: Boolean = false, + val selectedFee: SendFee? = null, + val feeOptions: List = emptyList(), + val hasMultipleFeeOptions: Boolean = false, + val signingState: SigningState = SigningState.Idle, + val isMax: Boolean = false, + val error: ConfirmationError? = null, + val estimatedDurationSeconds: Int? = null, + val withdrawalFeeFormatted: CharSequence? = null, + val withdrawalFeeFiatFormatted: CharSequence? = null, + val totalFormatted: CharSequence? = null, + val totalFiatFormatted: CharSequence? = null, + val exchangeData: SendParams.Exchange? = null, + ) : ConfirmState { + val isExchangeMode: Boolean get() { + return exchangeData != null + } + } + + data class Error(val message: String) : ConfirmState +} + +enum class SigningState { Idle, Loading, Success, Failed } + +class ConfirmViewState( + val global: MviProperty, +) : MviViewState + +sealed interface ConfirmEvent { + data class ShowInsufficientBalance( + val balance: Amount, + val required: Amount, + val withRechargeBattery: Boolean, + val singleWallet: Boolean, + val type: InsufficientBalanceType, + ) : ConfirmEvent +} + +// endregion + +class ConfirmFeature( + val params: SendParams, + private val app: Application, // TODO remove from here + private val accountRepository: AccountRepository, + private val api: API, + private val settingsRepository: SettingsRepository, + private val tokenRepository: TokenRepository, + private val ratesRepository: RatesRepository, + private val batteryRepository: BatteryRepository, + private val transactionManager: TransactionManager, + private val emulationUseCase: EmulationUseCase, + private val signUseCase: SignUseCase, +) : MviFeature( + initState = ConfirmState.Loading, + initAction = ConfirmAction.Init, +) { + + private val relay = MviRelay() + val events = relay.events + + // region Internal State + + private val currency: WalletCurrency get() = params.currency + private val isNft: Boolean get() = params.nftAddress.isNotBlank() + + private val isBatteryDisabled: Boolean + get() = api.getConfig(wallet.network).flags.disableBattery + + private lateinit var wallet: WalletEntity + private var tonTransfer: TransferEntity? = null + private var tronTransfer: TronTransfer? = null + private var tronResources: TronResourcesEntity? = null + + private var tonFee: SendFee.Ton? = null + private var gaslessFee: SendFee.Gasless? = null + private var batteryFee: SendFee.Battery? = null + private var tronTrxFee: SendFee.TronTrx? = null + private var tronTonFee: SendFee.TronTon? = null + + private val feeOptions: List + get() = listOfNotNull(batteryFee, tonFee, gaslessFee, tronTonFee, tronTrxFee) + + private val queryId: BigInteger = TransferEntity.newWalletQueryId() + private var tokenCustomPayload: TokenEntity.TransferPayload? = null + + // endregion + + private fun getWithdrawFeePaidIn(fee: SendFee?): Events.WithdrawFlow.WithdrawFlowFeePaidIn { + return when (fee) { + is SendFee.Ton -> Events.WithdrawFlow.WithdrawFlowFeePaidIn.Ton + is SendFee.Gasless -> Events.WithdrawFlow.WithdrawFlowFeePaidIn.Gasless + is SendFee.Battery -> Events.WithdrawFlow.WithdrawFlowFeePaidIn.Battery + is SendFee.TronTrx -> Events.WithdrawFlow.WithdrawFlowFeePaidIn.Trx + is SendFee.TronTon -> Events.WithdrawFlow.WithdrawFlowFeePaidIn.Ton + null -> Events.WithdrawFlow.WithdrawFlowFeePaidIn.Ton + } + } + + private fun getSellAsset(token: TokenEntity): Events.WithdrawFlow.WithdrawFlowSellAsset { + return when { + token.address == WalletCurrency.USDT_TON.address -> Events.WithdrawFlow.WithdrawFlowSellAsset.TonJettonUSDT + token.address == WalletCurrency.USDT_TRON.address -> Events.WithdrawFlow.WithdrawFlowSellAsset.TronTrc20USDT + else -> Events.WithdrawFlow.WithdrawFlowSellAsset.TonNativeTON + } + } + + private fun getFeePaidIn(fee: SendFee?): Events.SendNative.SendNativeFeePaidIn { + return when (fee) { + is SendFee.Ton -> Events.SendNative.SendNativeFeePaidIn.Ton + is SendFee.Gasless -> Events.SendNative.SendNativeFeePaidIn.Gasless + is SendFee.Battery -> Events.SendNative.SendNativeFeePaidIn.Battery + is SendFee.TronTrx -> Events.SendNative.SendNativeFeePaidIn.Trx + is SendFee.TronTon -> Events.SendNative.SendNativeFeePaidIn.Ton + null -> Events.SendNative.SendNativeFeePaidIn.Ton + } + } + + override fun createViewState(): ConfirmViewState { + return buildViewState { + ConfirmViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: ConfirmAction) { + when (action) { + is ConfirmAction.Init -> handleInit() + is ConfirmAction.SelectFee -> handleSelectFee(action.fee) + is ConfirmAction.Sign -> handleSign(action.context) + is ConfirmAction.Retry -> { + setState { + copy( + error = null, + signingState = SigningState.Idle + ) + } + handleSign(action.context) + } + } + } + + // region Init + + private suspend fun handleInit() { + wallet = accountRepository.getWalletById(params.walletId) + ?: return // TODO hide + + val operationId = generateUuid() + val startedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opAttempt( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.Transfer, + operation = Events.RedOperations.RedOperationsOperation.Emulate, + attemptSource = null, + startedAtMs = currentTimeSecondsInt(), + otherMetadata = WalletRedMetadata.walletKit(), + ) + try { + if (params.selectedToken.isTrc20) { + initTron() + } else { + initTon() + } + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.Transfer, + operation = Events.RedOperations.RedOperationsOperation.Emulate, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + error = null, + otherMetadata = WalletRedMetadata.walletKit(), + ) + } catch (e: Throwable) { + L.e("ConfirmFeature", "Init error", e) + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.Transfer, + operation = Events.RedOperations.RedOperationsOperation.Emulate, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + error = e, + otherMetadata = WalletRedMetadata.walletKit(), + ) + setState { ConfirmState.Error(e.message ?: "Unknown error") } + } + } + + private suspend fun initTon() { + withContext(Dispatchers.IO) { + val transfer = buildTonTransfer() + tonTransfer = transfer + val fee = calculateFee(transfer) + + val insufficientInfo = checkInsufficientBalance(fee, transfer) + if (insufficientInfo != null) { + relay.emit(insufficientInfo) + setState { ConfirmState.Error("Insufficient balance") } + return@withContext + } + + transitionToReady(fee) + } + } + + private suspend fun initTron() { + withContext(Dispatchers.IO) { + val transfer = buildTronTransfer() + tronTransfer = transfer + + resetFees() + val resources = api.tron.estimateTransferResources(transfer) + tronResources = resources + + coroutineScope { + val batteryFeeDeferred = async { getTronBatteryFee(transfer, resources) } + val trxFeeDeferred = async { getTronTrxFee(resources) } + + batteryFee = batteryFeeDeferred.await() + tronTonFee = getTronTonFee(batteryFee?.estimatedTron) + tronTrxFee = trxFeeDeferred.await() + } + + var fee: SendFee? = null + + // Default selection + if (batteryFee != null && batteryFee!!.enoughCharges) fee = batteryFee + else if (tronTonFee != null && tronTonFee!!.enoughBalance) fee = tronTonFee + else if (tronTrxFee != null && tronTrxFee!!.enoughBalance) fee = tronTrxFee + + // Preferred method override + val preferredFeeMethod = settingsRepository.getPreferredTronFeeMethod(wallet.id) + if (preferredFeeMethod == PreferredTronFeeMethod.BATTERY && batteryFee?.enoughCharges == true) fee = + batteryFee + else if (preferredFeeMethod == PreferredTronFeeMethod.TON && tronTonFee?.enoughBalance == true) fee = + tronTonFee + else if (preferredFeeMethod == PreferredTronFeeMethod.TRX && tronTrxFee?.enoughBalance == true) fee = + tronTrxFee + + if (fee != null) { + transitionToReady(fee) + } else { + relay.emit( + ConfirmEvent.ShowInsufficientBalance( + balance = Amount(tronTrxFee!!.balance, TokenEntity.TRX), + required = Amount(tronTrxFee!!.amount.value, TokenEntity.TRX), + singleWallet = 1 >= getWalletCount(), + withRechargeBattery = false, + type = InsufficientBalanceType.InsufficientBalanceForFee, + ) + ) + setState { ConfirmState.Error("Insufficient balance for fee") } + } + } + } + + private suspend fun transitionToReady(fee: SendFee) { + val token = params.selectedToken + val dest = params.destination + val tokenAmount = params.tokenAmount + + val rates = ratesRepository.getRates(wallet.network, currency, token.address) + + // Adjust amount for max + fee + var displayAmount = tokenAmount + if (params.isMaxAmount) { + when { + fee is SendFee.Gasless -> displayAmount = tokenAmount - fee.amount.value + token.isTon && fee is SendFee.Ton -> displayAmount = tokenAmount - fee.amount.value + } + if (displayAmount.isNegative) displayAmount = Coins.ZERO + } + + val amountFormatted = + CurrencyFormatter.formatFull(token.symbol, displayAmount, token.balance.token.decimals) + val amountFiatFormatted = + CurrencyFormatter.formatFiat(currency.code, rates.convert(token.address, displayAmount)) + + val feeDisplay = formatFeeDisplay(fee) + + val recipientDisplay = when { + params.exchangeData != null -> params.exchangeData.exchangeAddress + else -> when (dest) { + is SendDestination.TonAccount -> dest.displayName + is SendDestination.TronAccount -> null + else -> null + } + } + + val recipientAddress = when (dest) { + is SendDestination.TonAccount -> dest.displayAddress + is SendDestination.TronAccount -> dest.address + else -> "" + } + + // Compute total for exchange mode: amount + network fee + withdrawal fee + val totalFormatted: CharSequence? + val totalFiatFormatted: CharSequence? + if (params.exchangeData != null) { + val networkFeeAmount = when (fee) { + is SendFee.Gasless -> fee.amount.value + else -> Coins.ZERO + } + + val total = displayAmount + networkFeeAmount + totalFormatted = CurrencyFormatter.formatFull(token.symbol, total, token.balance.token.decimals) + val totalFiat = rates.convert(token.address, total) + totalFiatFormatted = CurrencyFormatter.formatFiat(currency.code, totalFiat) + } else { + totalFormatted = null + totalFiatFormatted = null + } + + setState { + ConfirmState.Ready( + token = token.balance.token, + walletName = wallet.label.name, + recipientDisplay = recipientDisplay, + recipientAddress = recipientAddress, + amountFormatted = amountFormatted, + amountFiatFormatted = amountFiatFormatted, + feeFormatted = feeDisplay.first, + feeFiatFormatted = feeDisplay.second, + comment = params.comment, + encryptedComment = params.encryptedComment, + selectedFee = fee, + feeOptions = feeOptions, + hasMultipleFeeOptions = feeOptions.size > 1, + isMax = params.isMaxAmount, + displayCurrency = params.exchangeData?.currency, + estimatedDurationSeconds = params.exchangeData?.estimatedDurationSeconds, + withdrawalFeeFormatted = params.exchangeData?.withdrawalFee?.let { fee -> + val feeCoins = runCatching { Coins.of(fee) }.getOrNull() ?: return@let null + "≈ ${CurrencyFormatter.format(token.symbol, feeCoins, replaceSymbol = false)}" + }, + withdrawalFeeFiatFormatted = params.exchangeData?.withdrawalFee?.let { fee -> + val feeCoins = runCatching { Coins.of(fee) }.getOrNull() ?: return@let null + val fiatAmount = rates.convert(token.address, feeCoins) + "≈ ${CurrencyFormatter.formatFiat(currency.code, fiatAmount)}" + }, + totalFormatted = totalFormatted, + totalFiatFormatted = totalFiatFormatted, + exchangeData = params.exchangeData, + ) + } + } + + // endregion + + // region Fee Selection + + private suspend fun handleSelectFee(fee: SendFee) = withContext(Dispatchers.IO) { + if (fee is SendFee.TronTrx && !fee.enoughBalance) return@withContext + if (fee is SendFee.TronTon && !fee.enoughBalance) return@withContext + if (fee is SendFee.Battery && !fee.enoughCharges) return@withContext + + // Persist preferred fee method + if (params.selectedToken.isTrc20) { + when (fee) { + is SendFee.Battery -> settingsRepository.setPreferredTronFeeMethod( + wallet.id, + PreferredTronFeeMethod.BATTERY + ) + + is SendFee.TronTon -> settingsRepository.setPreferredTronFeeMethod( + wallet.id, + PreferredTronFeeMethod.TON + ) + + is SendFee.TronTrx -> settingsRepository.setPreferredTronFeeMethod( + wallet.id, + PreferredTronFeeMethod.TRX + ) + + else -> {} + } + } else { + when (fee) { + is SendFee.Battery -> settingsRepository.setPreferredFeeMethod( + wallet.id, + PreferredFeeMethod.BATTERY + ) + + is SendFee.Gasless -> settingsRepository.setPreferredFeeMethod( + wallet.id, + PreferredFeeMethod.GASLESS + ) + + is SendFee.Ton -> settingsRepository.setPreferredFeeMethod( + wallet.id, + PreferredFeeMethod.TON + ) + + else -> {} + } + } + + // Update confirm state with new fee + val feeDisplay = formatFeeDisplay(fee) + + // Recompute total for exchange mode + val updatedTotal = if (params.exchangeData != null) { + val token = params.selectedToken + val rates = ratesRepository.getRates(wallet.network, currency, token.address) + val networkFeeAmount = (fee as? SendFee.Gasless)?.amount?.value ?: Coins.ZERO + val total = params.tokenAmount + networkFeeAmount + val totalFormatted = CurrencyFormatter.formatFull(token.symbol, total, token.balance.token.decimals) + val totalFiat = rates.convert(token.address, total) + val totalFiatFormatted = CurrencyFormatter.formatFiat(currency.code, totalFiat) + totalFormatted to totalFiatFormatted + } else null + + setState { + copy( + selectedFee = fee, + feeFormatted = feeDisplay.first, + feeFiatFormatted = feeDisplay.second, + totalFormatted = updatedTotal?.first ?: totalFormatted, + totalFiatFormatted = updatedTotal?.second ?: totalFiatFormatted, + ) + } + } + + // endregion + + // region Signing + + private suspend fun handleSign(context: Context) { + setState { copy(signingState = SigningState.Loading) } + + val analyticsFrom = params.analyticsFrom + val token = params.selectedToken.balance.token + val amount = params.tokenAmount.value.toDouble() + val readyState = obtainSpecificState() + val feePaidIn = getFeePaidIn(readyState?.selectedFee) + + AnalyticsHelper.Default.events.sendNative.sendConfirm( + from = analyticsFrom, + assetNetwork = token.blockchain.id, + tokenSymbol = token.symbol, + amount = amount, + feePaidIn = feePaidIn, + appId = null, + ) + + if (params.exchangeData != null) { + AnalyticsHelper.Default.events.withdrawFlow.withdrawSendConfirm( + sellAsset = getSellAsset(token), + assetNetwork = token.blockchain.id, + tokenSymbol = token.symbol, + amount = amount, + feePaidIn = getWithdrawFeePaidIn(readyState?.selectedFee), + ) + } + + val operationId = generateUuid() + val startedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opAttempt( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.Transfer, + operation = Events.RedOperations.RedOperationsOperation.Send, + attemptSource = null, + startedAtMs = currentTimeSecondsInt(), + otherMetadata = WalletRedMetadata.walletKit(), + ) + + try { + val state = obtainSpecificState() + + if ( + state?.selectedFee is SendFee.TronTrx + || state?.selectedFee is SendFee.TronTon + || state?.selectedFee is SendFee.Battery + && tronTransfer != null + ) { + signTron(context) + } else { + signTon(context) + } + + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.Transfer, + operation = Events.RedOperations.RedOperationsOperation.Send, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + error = null, + otherMetadata = WalletRedMetadata.walletKit(), + ) + + AnalyticsHelper.Default.events.sendNative.sendSuccess( + from = analyticsFrom, + assetNetwork = token.blockchain.id, + tokenSymbol = token.symbol, + amount = amount, + feePaidIn = feePaidIn, + appId = null, + ) + + if (params.exchangeData != null) { + AnalyticsHelper.Default.events.withdrawFlow.withdrawSendSuccess( + sellAsset = getSellAsset(token), + assetNetwork = token.blockchain.id, + tokenSymbol = token.symbol, + amount = amount, + feePaidIn = getWithdrawFeePaidIn(readyState?.selectedFee), + ) + } + + setState { copy(signingState = SigningState.Success) } + } catch (e: Throwable) { + L.e("ConfirmFeature", "Sign error", e) + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.Transfer, + operation = Events.RedOperations.RedOperationsOperation.Send, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + error = e, + otherMetadata = WalletRedMetadata.walletKit(), + ) + if (e is CancellationException) { + setState { copy(signingState = SigningState.Idle) } + } else { + + AnalyticsHelper.Default.events.sendNative.sendFailed( + from = analyticsFrom, + assetNetwork = token.blockchain.id, + tokenSymbol = token.symbol, + amount = amount, + feePaidIn = feePaidIn, + errorCode = 0, + errorMessage = e.message ?: "unknown", + appId = null, + ) + + val error = when (e) { + is SendException.InsufficientBalance -> ConfirmationError.InsufficientBalance + is SendBlockchainException -> e.getUserMessage(context) + ?.let { ConfirmationError.Message(it) } ?: ConfirmationError.Unknown + + else -> ConfirmationError.Unknown + } + + setState { + copy(signingState = SigningState.Failed, error = error) + } + } + } + } + + private suspend fun signTon(context: Context) { + val transfer = tonTransfer ?: throw IllegalStateException("Ton transfer is null") + val confirmState = obtainSpecificState() + val fee = confirmState?.selectedFee ?: throw IllegalStateException("Fee is null") + + // Battery charge validation + if (fee is SendFee.Battery) { + val batteryCharges = getBatteryCharges() + val batteryConfig = batteryRepository.getConfig(wallet.network) + val txCharges = BatteryMapper.calculateChargesAmount( + Coins.of(abs(fee.extra)).value, + batteryConfig.chargeCost, + ) + if (txCharges > batteryCharges) { + throw SendException.InsufficientBalance() + } + } + + val excessesAddress = if (fee is SendFee.RelayerFee) fee.excessesAddress else null + val additionalGifts = if (fee is SendFee.Gasless) { + listOf( + transfer.gaslessInternalGift( + jettonAmount = fee.amount.value, + batteryAddress = fee.excessesAddress + ) + ) + } else emptyList() + + val privateKey = if (transfer.commentEncrypted) { + accountRepository.getPrivateKey(wallet.id) + } else null + + val internalMessage = excessesAddress != null + + val jettonTransferAmount = when (fee) { + is SendFee.Gasless -> TransferEntity.BASE_FORWARD_AMOUNT + is SendFee.Extra -> { + val extra = Coins.of(fee.extra) + when { + transfer.token.isRequestMinting || transfer.token.customPayloadApiUri != null -> TransferEntity.POINT_ONE_TON + extra.isPositive -> TransferEntity.BASE_FORWARD_AMOUNT + extra.isZero -> TransferEntity.POINT_ONE_TON + else -> Coins.of(abs(fee.extra)) + TransferEntity.BASE_FORWARD_AMOUNT + } + } + + is SendFee.Ton -> when { + transfer.token.isRequestMinting || transfer.token.customPayloadApiUri != null -> TransferEntity.POINT_ONE_TON + fee.amount.isRefund -> TransferEntity.BASE_FORWARD_AMOUNT + else -> fee.amount.value + TransferEntity.BASE_FORWARD_AMOUNT + } + + else -> TransferEntity.POINT_ONE_TON + } + + val boc = signUseCase( + context = context, + wallet = wallet, + unsignedBody = transfer.getUnsignedBody( + privateKey = privateKey, + internalMessage = internalMessage, + additionalGifts = additionalGifts, + excessesAddress = excessesAddress, + jettonAmount = if (transfer.max && fee is SendFee.Gasless) { + transfer.amount - fee.amount.value + } else null, + jettonTransferAmount = jettonTransferAmount, + ), + seqNo = transfer.seqno, + ledgerTransaction = transfer.getLedgerTransaction(jettonTransferAmount), + ) + + send(boc, internalMessage) + } + + private suspend fun signTron(context: Context) { + val confirmState = obtainSpecificState() + val fee = confirmState?.selectedFee ?: throw IllegalStateException("Fee is null") + val transfer = tronTransfer ?: throw IllegalStateException("Tron transfer is null") + val transaction = api.tron.buildSmartContractTransaction(transfer).extendExpiration() + val resources = tronResources ?: throw IllegalStateException("Tron resources is null") + val privateKey = accountRepository.getPrivateKey(wallet.id) + ?: throw IllegalStateException("Private key is null") + + val signedTransaction = signUseCase( + context = context, + wallet = wallet, + transaction = transaction, + ) + + val tonProofToken = accountRepository.requestTonProofToken(wallet) + ?: throw IllegalStateException("TonProofToken is null") + + when (fee) { + is SendFee.Battery -> { + api.tron.sendWithBattery( + transaction = signedTransaction, + resources = resources, + tronAddress = transfer.from, + tonProofToken = tonProofToken, + ) + } + + is SendFee.TronTon -> { + val tonToken = tokenRepository.getTON(currency, wallet.accountId, wallet.network) + ?: throw IllegalStateException("TON token not found") + val tonProofToken = accountRepository.requestTonProofToken(wallet) + ?: throw IllegalStateException("TonProofToken is null") + val sendMetadata = getSendParams() + val instantFeeTx = TransferEntity.Builder(wallet) + .setToken(tonToken.balance) + .setSeqno(sendMetadata.seqno) + .setValidUntil(sendMetadata.validUntil) + .setAmount(fee.amount.value) + .setComment("Tron gas fee", false) + .setDestination(AddrStd(fee.sendToAddress), EmptyPrivateKeyEd25519.publicKey()) + .build() + .sign( + privateKey = privateKey, + jettonTransferAmount = TransferEntity.BASE_FORWARD_AMOUNT + ) + + api.tron.sendWithTon( + transaction = signedTransaction, + instantFeeTx = instantFeeTx, + resources = resources, + tronAddress = transfer.from, + userPublicKey = wallet.publicKey.base64(), + batteryAuthToken = tonProofToken, + ) + } + + is SendFee.TronTrx -> { + api.tron.sendWithTrx( + transaction = signedTransaction, + resources = resources, + tronAddress = transfer.from, + ) + } + + else -> throw IllegalStateException("Invalid fee type for tron transfer") + } + } + + private suspend fun send(message: Cell, withBattery: Boolean) { + transactionManager.send( + wallet = wallet, + boc = message, + withBattery = withBattery, + source = "", + confirmationTime = 0.0, + ) + } + + // endregion + + // region Transfer Building + + private suspend fun buildTonTransfer(): TransferEntity = withContext(Dispatchers.IO) { + val tokenAmount = params.tokenAmount + if (tokenAmount.isZero && !isNft) { + throw IllegalStateException("Amount is empty") + } + + val comment = params.comment?.ifBlank { null } + val destination = params.destination as? SendDestination.TonAccount + ?: throw IllegalStateException("Destination is not TonAccount") + + val token = params.selectedToken + val customPayload = getTokenCustomPayload(token.balance.token) + val sendMetadata = getSendParams() + + val builder = TransferEntity.Builder(wallet) + if (!customPayload.isEmpty) { + builder.setTokenPayload(customPayload) + } + builder.setToken(token.balance) + builder.setDestination(destination.address, destination.publicKey) + builder.setSeqno(sendMetadata.seqno) + builder.setQueryId(queryId) + comment?.let { builder.setComment(it, params.encryptedComment) } + builder.setValidUntil(sendMetadata.validUntil) + + if (isNft) { + builder.setNftAddress(params.nftAddress) + builder.setBounceable(true) + builder.setAmount(Coins.ZERO) + builder.setMax(false) + } else if (!token.isTon) { + val isMax = tokenAmount == token.balance.uiBalance + builder.setMax(isMax) + builder.setBounceable(true) + builder.setAmount( + if (isMax) token.balance.value else token.balance.fromUIBalance( + tokenAmount + ) + ) + } else { + val tonBalance = getTONBalance() + builder.setMax(tokenAmount == tonBalance) + builder.setAmount(tokenAmount) + builder.setBounceable(destination.isBounce) + } + + builder.build() + } + + private suspend fun buildTronTransfer(): TronTransfer = withContext(Dispatchers.IO) { + val tokenAmount = params.tokenAmount + val destination = params.destination as? SendDestination.TronAccount + ?: throw IllegalStateException("Destination is not TronAccount") + val tronAddress = accountRepository.getTronAddress(wallet.id) + ?: throw IllegalStateException("Tron address not found") + + TronTransfer( + from = tronAddress, + to = destination.address, + amount = tokenAmount.toBigInteger(), + contractAddress = params.selectedToken.address, + ) + } + + // endregion + + // region Fee Calculation + + private fun resetFees() { + tonFee = null + gaslessFee = null + batteryFee = null + tronTonFee = null + tronTrxFee = null + } + + private suspend fun calculateFee(transfer: TransferEntity): SendFee = + withContext(Dispatchers.IO) { + resetFees() + val withRelayer = shouldAttemptWithRelayer(transfer) + val tonProofToken = accountRepository.requestTonProofToken(wallet) + val batteryConfig = batteryRepository.getConfig(wallet.network) + val tokenAddress = transfer.token.token.address + val excessesAddress = batteryConfig.excessesAddress + val isGaslessToken = !transfer.token.isTon && batteryConfig.rechargeMethods.any { + it.supportGasless && it.jettonMaster == tokenAddress + } + val isSupportsGasless = wallet.isSupportedFeature(WalletFeature.GASLESS) && + tonProofToken != null && excessesAddress != null && isGaslessToken + + coroutineScope { + val tonDeferred = async { calculateFeeDefault(transfer) } + val gaslessDeferred = async { + if (isSupportsGasless) { + calculateFeeGasless( + transfer, + excessesAddress!!, + tonProofToken!!, + tokenAddress + ) + } else null + } + val batteryDeferred = async { + if (withRelayer && tonProofToken != null && excessesAddress != null) { + calculateFeeBattery(transfer, excessesAddress, tonProofToken) + } else null + } + + val tonFeeResult = tonDeferred.await() + gaslessFee = gaslessDeferred.await() + batteryFee = batteryDeferred.await() + + tonFee = if (tonFeeResult.error is InsufficientBalanceError) null else tonFeeResult + + // Preferred fee method + val preferredFeeMethod = settingsRepository.getPreferredFeeMethod(wallet.id) + if (preferredFeeMethod == PreferredFeeMethod.BATTERY && batteryFee != null) return@coroutineScope batteryFee!! + if (preferredFeeMethod == PreferredFeeMethod.GASLESS && gaslessFee != null) return@coroutineScope gaslessFee!! + if (preferredFeeMethod == PreferredFeeMethod.TON && tonFee != null) return@coroutineScope tonFee!! + + // Default selection + if (batteryFee != null) return@coroutineScope batteryFee!! + if (gaslessFee != null && tonFee == null) return@coroutineScope gaslessFee!! + + return@coroutineScope tonFeeResult + } + } + + private suspend fun calculateFeeDefault(transfer: TransferEntity): SendFee.Ton { + val jettonTransferAmount = getJettonTransferAmount(transfer) + val emulated = emulationUseCase( + message = transfer.getEmulationBody(jettonTransferAmount), + params = true, + checkTonBalance = !transfer.isTon || !transfer.max, + ) + val fee = Fee(emulated.extra.value, emulated.extra.isRefund) + return SendFee.Ton( + amount = fee, + fiatAmount = emulated.extra.fiat, + fiatCurrency = currency, + error = emulated.error, + ) + } + + private suspend fun calculateFeeBattery( + transfer: TransferEntity, + excessesAddress: AddrStd, + tonProofToken: String, + ): SendFee.Battery? { + if (api.getConfig(wallet.network).batterySendDisabled) { + return null + } + + val message = transfer.signForEstimation( + internalMessage = true, + excessesAddress = excessesAddress, + jettonTransferAmount = TransferEntity.BASE_FORWARD_AMOUNT, + ) + + try { + val result = batteryRepository.emulate( + tonProofToken = tonProofToken, + publicKey = wallet.publicKey, + network = wallet.network, + boc = message, + safeModeEnabled = settingsRepository.isSafeModeEnabled(wallet.network), + ) ?: return null + + if (!result.withBattery) return null + + val extra = result.consequences.event.extra + val tonAmount = Coins.of(abs(extra)) + val chargesBalance = getBatteryCharges() + val batteryConfig = batteryRepository.getConfig(wallet.network) + val charges = + BatteryMapper.calculateChargesAmount(tonAmount.value, batteryConfig.chargeCost) + + if (charges > chargesBalance) return null + + val excess = result.excess + val excessCharges = when { + excess.isPositive() -> BatteryMapper.calculateChargesAmount( + Coins.of(excess).value, + batteryConfig.chargeCost + ).toLong() + + else -> null + } + + return SendFee.Battery( + charges = charges, + chargesBalance = chargesBalance, + extra = extra, + excessesAddress = excessesAddress, + fiatAmount = ratesRepository.getRates( + wallet.network, + currency, + TokenEntity.TON.address + ) + .convert(TokenEntity.TON.address, tonAmount), + fiatCurrency = currency, + excessCharges = excessCharges, + ) + } catch (_: Exception) { + return null + } + } + + private suspend fun calculateFeeGasless( + transfer: TransferEntity, + excessesAddress: AddrStd, + tonProofToken: String, + tokenAddress: String, + ): SendFee.Gasless? { + try { + if (api.getConfig(wallet.network).flags.disableGasless) return null + + val message = transfer.signForEstimation( + internalMessage = true, + jettonAmount = if (transfer.max) Coins.of(1, transfer.token.decimals) else null, + additionalGifts = listOf( + transfer.gaslessInternalGift( + jettonAmount = Coins.of(1, transfer.token.decimals), + batteryAddress = excessesAddress, + ) + ), + excessesAddress = excessesAddress, + jettonTransferAmount = TransferEntity.BASE_FORWARD_AMOUNT, + ) + + val commission = api.estimateGaslessCost( + tonProofToken = tonProofToken, + jettonMaster = tokenAddress, + cell = message, + network = wallet.network, + ) ?: return null + + val gaslessFeeAmount = Coins.ofNano(commission, transfer.token.decimals) + + if (transfer.max && gaslessFeeAmount > transfer.token.value) return null + if (!transfer.max && gaslessFeeAmount + transfer.amount > transfer.token.value) return null + + val fee = Fee(value = gaslessFeeAmount, isRefund = false, token = transfer.token.token) + val rates = ratesRepository.getRates(wallet.network, currency, fee.token.address) + val converted = rates.convert(fee.token.address, fee.value) + + return SendFee.Gasless( + amount = fee, + fiatAmount = converted, + fiatCurrency = currency, + excessesAddress = excessesAddress, + ) + } catch (e: Exception) { + L.d("ConfirmFeature", "Gasless fee failed: ${e.message}") + return null + } + } + + // region Tron Fee + + private suspend fun getTronBatteryFee( + transfer: TronTransfer, + resources: TronResourcesEntity, + ): SendFee.Battery? { + try { + val batteryCharges = getBatteryCharges() + if (isBatteryDisabled && batteryCharges == 0) return null + + val batteryEstimation = api.tron.estimateBatteryCharges(transfer, resources) + val batteryConfig = batteryRepository.getConfig(wallet.network) + val tonAmount = BatteryMapper.convertFromCharges( + batteryEstimation.charges, + batteryConfig.chargeCost + ) + + val fee = SendFee.Battery( + charges = batteryEstimation.charges, + chargesBalance = batteryCharges, + fiatAmount = ratesRepository.getRates( + wallet.network, + currency, + TokenEntity.TON.address + ) + .convert(TokenEntity.TON.address, tonAmount), + fiatCurrency = currency, + excessesAddress = AddrStd(wallet.address), + extra = 0L, + estimatedTron = batteryEstimation.estimated, + ) + + if (isBatteryDisabled && fee.charges > fee.chargesBalance) return null + return fee + } catch (_: Exception) { + return null + } + } + + private suspend fun getTronTonFee( + batteryEstimated: EstimatedTronTx?, + ): SendFee.TronTon? { + if (isBatteryDisabled || batteryEstimated == null) return null + + try { + val tonEstimation = api.tron.estimateTonFee(batteryEstimated) + return SendFee.TronTon( + amount = Fee(value = tonEstimation.fee, isRefund = false), + balance = getTONBalance(), + fiatAmount = ratesRepository.getRates( + wallet.network, + currency, + TokenEntity.TON.address + ) + .convert(TokenEntity.TON.address, tonEstimation.fee), + fiatCurrency = currency, + sendToAddress = tonEstimation.sendToAddress, + ) + } catch (_: Exception) { + return null + } + } + + private suspend fun getTronTrxFee(resources: TronResourcesEntity): SendFee.TronTrx { + val trxEstimation = api.tron.estimateTrxFee(resources) + val trxBalance = getTrxBalance() + return SendFee.TronTrx( + amount = Fee(value = trxEstimation.fee, isRefund = false, token = TokenEntity.TRX), + fiatAmount = ratesRepository.getRates(wallet.network, currency, TokenEntity.TRX.address) + .convert(TokenEntity.TRX.address, trxEstimation.fee), + fiatCurrency = currency, + balance = trxBalance, + ) + } + + // endregion + + // endregion + + // region Display Helpers + + private suspend fun formatFeeDisplay(fee: SendFee): Pair { + return when (fee) { + is SendFee.TokenFee -> { + val formatted = CurrencyFormatter.format(fee.amount.token.symbol, fee.amount.value) + val rates = + ratesRepository.getRates(wallet.network, currency, fee.amount.token.address) + val fiatConverted = rates.convert(fee.amount.token.address, fee.amount.value) + val fiatFormatted = CurrencyFormatter.format(currency.code, fiatConverted) + "≈ $formatted" to "≈ $fiatFormatted" + } + + is SendFee.Battery -> { + val formatted = app.resources.getQuantityString( + Plurals.battery_charges, + fee.charges, + CurrencyFormatter.format(value = fee.charges.toBigDecimal()) + ) + val excessCharges = fee.excessCharges + val description = if (excessCharges.isPositive()) { + "≈ ${ + app.getString( + Localization.battery_excess, CurrencyFormatter.format( + value = excessCharges.toBigDecimal() + ) + ) + }" + } else { + app.getString( + Localization.out_of_available_charges, + CurrencyFormatter.format(value = fee.chargesBalance.toBigDecimal()) + ) + } + "≈ $formatted" to description + } + + else -> "" to "" + } + } + + private fun checkInsufficientBalance( + fee: SendFee, + transfer: TransferEntity, + ): ConfirmEvent.ShowInsufficientBalance? { + if (fee is SendFee.Ton && fee.error is InsufficientBalanceError) { + return ConfirmEvent.ShowInsufficientBalance( + type = InsufficientBalanceType.InsufficientTONBalance, + balance = Amount(value = fee.error.accountBalance), + required = Amount(value = fee.error.totalAmount), + withRechargeBattery = false, + singleWallet = false, + ) + } + if (fee is SendFee.Gasless && !transfer.max && fee.amount.value + transfer.amount > transfer.token.value) { + return ConfirmEvent.ShowInsufficientBalance( + type = InsufficientBalanceType.InsufficientGaslessBalance, + balance = Amount(value = transfer.token.value, token = transfer.token.token), + required = Amount( + value = fee.amount.value + transfer.amount, + token = transfer.token.token + ), + withRechargeBattery = false, + singleWallet = false, + ) + } + if (!transfer.isTon && !transfer.isNft && transfer.amount > transfer.token.value) { + return ConfirmEvent.ShowInsufficientBalance( + type = InsufficientBalanceType.InsufficientJettonBalance, + balance = Amount(value = transfer.token.value, token = transfer.token.token), + required = Amount(value = transfer.amount, token = transfer.token.token), + withRechargeBattery = false, + singleWallet = false, + ) + } + return null + } + + // endregion + + // region Helpers + + private suspend fun getSendParams(): SendMetadataEntity = withContext(Dispatchers.IO) { + coroutineScope { + val seqnoDeferred = async { accountRepository.getSeqno(wallet) } + val validUntilDeferred = async { accountRepository.getValidUntil(wallet.network) } + SendMetadataEntity( + seqno = seqnoDeferred.await(), + validUntil = validUntilDeferred.await(), + ) + } + } + + private suspend fun getBatteryCharges(): Int = withContext(Dispatchers.IO) { + accountRepository.requestTonProofToken(wallet)?.let { + batteryRepository.getCharges(it, wallet.publicKey, wallet.network, true) + } ?: 0 + } + + private fun getTONBalance(): Coins { + if (params.selectedToken.isTon) { + return params.selectedToken.balance.value + } + return params.tokens.find { it.isTon }?.balance?.value ?: Coins.ZERO + } + + private fun getTrxBalance(): Coins { + return params.tokens.find { it.isTrx }?.balance?.value ?: Coins.ZERO + } + + private suspend fun getWalletCount(): Int = withContext(Dispatchers.IO) { + accountRepository.getWallets().size + } + + private fun shouldAttemptWithRelayer(transfer: TransferEntity): Boolean { + if ((transfer.isTon && !transfer.isNft) || transfer.wallet.isExternal) return false + val txType = if (transfer.isNft) BatteryTransaction.NFT else BatteryTransaction.JETTON + return settingsRepository.batteryIsEnabledTx(transfer.wallet.accountId, txType) + } + + private suspend fun getJettonTransferAmount(transfer: TransferEntity): Coins { + try { + if (transfer.token.isRequestMinting || transfer.token.customPayloadApiUri != null) { + return TransferEntity.POINT_ONE_TON + } + + val message = transfer.signForEstimation( + internalMessage = false, jettonTransferAmount = TransferEntity.ONE_TON + ) + val emulated = api.emulate( + cell = message, + network = transfer.wallet.network, + address = transfer.wallet.accountId, + balance = (Coins.ONE + Coins.ONE).toLong(), + safeModeEnabled = settingsRepository.isSafeModeEnabled(transfer.wallet.network), + ) + val fee = Fee(emulated?.event?.extra ?: 0L) + + return if (fee.isRefund) { + TransferEntity.BASE_FORWARD_AMOUNT + } else { + fee.value + TransferEntity.BASE_FORWARD_AMOUNT + } + } catch (_: Throwable) { + return TransferEntity.POINT_ONE_TON + } + } + + private fun getTokenCustomPayload(token: TokenEntity): TokenEntity.TransferPayload { + if (token.isTon) { + return TokenEntity.TransferPayload.empty("TON") + } + + if (!token.isRequestMinting) { + return TokenEntity.TransferPayload.empty(token.address) + } + + tokenCustomPayload?.let { + if (it.tokenAddress == token.address) return it + } + + return try { + val payload = api.getJettonCustomPayload( + accountId = wallet.accountId, + jettonId = token.address, + network = wallet.network, + ) ?: TokenEntity.TransferPayload.empty(token.address) + tokenCustomPayload = payload + payload + } catch (_: Throwable) { + TokenEntity.TransferPayload.empty(token.address) + } + } + + // endregion +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/InsufficientFundsDialog.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/InsufficientFundsDialog.kt new file mode 100644 index 000000000..ce55106ce --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/InsufficientFundsDialog.kt @@ -0,0 +1,180 @@ +package com.tonapps.deposit.screens.confirm + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.errors.InsufficientBalanceType +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonTopAppBarSimple +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.cell.MoonButtonCellDefaults +import ui.components.moon.dialog.MoonModalDialog +import ui.components.moon.dialog.rememberDialogNavigator +import ui.theme.UIKit + +@Composable +fun InsufficientFundsDialog( + event: ConfirmEvent.ShowInsufficientBalance, + onBuyTon: () -> Unit, + onGetTrx: () -> Unit, + onRechargeBattery: () -> Unit, + onClose: () -> Unit, +) { + val navigator = rememberDialogNavigator(onClose = onClose) + + MoonModalDialog(navigator = navigator) { + MoonTopAppBarSimple( + title = "", + actionIconRes = UIKitIcon.ic_close_16, + onActionClick = { navigator.close() }, + backgroundColor = Color.Transparent, + ) + InsufficientFundsContent( + event = event, + onBuyTon = { + navigator.close() + onBuyTon() + }, + onGetTrx = { + navigator.close() + onGetTrx() + }, + onRechargeBattery = { + navigator.close() + onRechargeBattery() + }, + ) + } +} + +@Composable +private fun InsufficientFundsContent( + event: ConfirmEvent.ShowInsufficientBalance, + onBuyTon: () -> Unit, + onGetTrx: () -> Unit, + onRechargeBattery: () -> Unit, +) { + val isBattery = event.type == InsufficientBalanceType.InsufficientBatteryChargesForFee + val isTrxFee = event.type == InsufficientBalanceType.InsufficientBalanceForFee + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(8.dp)) + + if (isBattery) { + // Battery icon placeholder — matches legacy BatteryView at MIN_LEVEL + Image( + painter = painterResource(UIKitIcon.ic_flash_24), + contentDescription = null, + modifier = Modifier.size(84.dp), + colorFilter = ColorFilter.tint(UIKit.colorScheme.icon.secondary), + ) + } else { + Image( + painter = painterResource(UIKitIcon.ic_exclamationmark_circle_84), + contentDescription = null, + modifier = Modifier.size(84.dp), + colorFilter = ColorFilter.tint(UIKit.colorScheme.icon.secondary), + ) + } + + Spacer(Modifier.height(24.dp)) + + // Title + Text( + text = when { + isBattery -> stringResource(Localization.insufficient_battery_charges) + isTrxFee -> stringResource(Localization.insufficient_trx_balance) + !event.singleWallet -> stringResource(Localization.insufficient_balance_in_wallet) + else -> stringResource(Localization.insufficient_balance_title) + }, + style = UIKit.typography.h2, + color = UIKit.colorScheme.text.primary, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(4.dp)) + + // Description + Text( + text = formatDescription(event), + style = UIKit.typography.body1, + color = UIKit.colorScheme.text.secondary, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(24.dp)) + + // Recharge Battery button + if (event.withRechargeBattery) { + MoonButtonCell( + text = stringResource(Localization.recharge_battery), + onClick = onRechargeBattery, + ) + } + + // Buy TON / Get TRX button + if (!isBattery) { + val buttonText = if (event.required.isTrx) { + stringResource(Localization.get_token, TokenEntity.TRX.symbol) + } else { + stringResource(Localization.buy_ton, event.required.symbol) + } + MoonButtonCell( + text = buttonText, + colors = if (event.withRechargeBattery) { + MoonButtonCellDefaults.ButtonColorsSecondary + } else { + MoonButtonCellDefaults.ButtonColorsPrimary + }, + onClick = if (event.required.isTrx) onGetTrx else onBuyTon, + ) + } + + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun formatDescription(event: ConfirmEvent.ShowInsufficientBalance): String { + val balance = event.balance + val required = event.required + + return if (event.type == InsufficientBalanceType.InsufficientBatteryChargesForFee) { + stringResource( + Localization.insufficient_balance_charges, + CurrencyFormatter.format(value = required.value), + CurrencyFormatter.format(value = balance.value), + ) + } else { + val balanceFormatted = CurrencyFormatter.formatFull(balance.symbol, balance.value, balance.decimals) + val requiredFormatted = CurrencyFormatter.formatFull(required.symbol, required.value, required.decimals) + val resId = if (event.withRechargeBattery || event.type == InsufficientBalanceType.InsufficientBalanceForFee) { + Localization.insufficient_balance_fees + } else { + Localization.insufficient_balance_default + } + stringResource(resId, requiredFormatted, balanceFormatted) + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/SendConfirmScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/SendConfirmScreen.kt new file mode 100644 index 000000000..a84e9120d --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/SendConfirmScreen.kt @@ -0,0 +1,897 @@ +package com.tonapps.deposit.screens.confirm + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.core.extensions.iconExternalUrl +import com.tonapps.core.helper.rememberClipboardManager +import com.tonapps.deposit.screens.send.state.SendFee +import com.tonapps.deposit.utils.formatDuration +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Links +import com.tonapps.wallet.localization.Localization +import com.tonapps.wallet.localization.Plurals +import kotlinx.coroutines.launch +import ui.components.moon.MoonItemDivider +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonItemSubtitle +import ui.components.moon.MoonItemTitle +import ui.components.moon.MoonLoadingPreviewImage +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonDescriptionCell +import ui.components.moon.cell.MoonSlideConfirmation +import ui.components.moon.cell.MoonSlideConfirmationState +import ui.components.moon.cell.TextCell +import ui.components.moon.cell.rememberSliderState +import ui.components.moon.container.BadgeDirection +import ui.components.moon.container.MoonCutBadgedBox +import ui.components.moon.container.MoonCutRow +import ui.components.moon.container.MoonScaffold +import ui.components.moon.container.MoonSurface +import ui.components.moon.dialog.MoonAlertDialog +import ui.preview.ThemedPreview +import ui.text.withLink +import ui.theme.LocalAppColorScheme +import ui.theme.UIKit +import ui.theme.modifiers.modifyIf +import ui.theme.modifiers.rememberShimmerPhase +import ui.theme.modifiers.shimmer +import ui.utils.toRichSpanStyle + +@Composable +fun SendConfirmScreen( + feature: ConfirmFeature, + onClose: () -> Unit, + onSendSuccess: () -> Unit, + onBack: () -> Unit, + onBuyTon: () -> Unit = {}, + onGetTrx: () -> Unit = {}, + onRechargeBattery: () -> Unit = {}, +) { + val state by feature.state.global.observeSafeState() + val lifecycle = LocalLifecycleOwner.current + + var insufficientFundsEvent by remember { + mutableStateOf(null) + } + + LaunchedEffect(Unit) { + feature.events.flowWithLifecycle(lifecycle.lifecycle).collect { event -> + when (event) { + is ConfirmEvent.ShowInsufficientBalance -> insufficientFundsEvent = event + } + } + } + + insufficientFundsEvent?.let { event -> + InsufficientFundsDialog( + event = event, + onBuyTon = onBuyTon, + onGetTrx = onGetTrx, + onRechargeBattery = onRechargeBattery, + onClose = { + insufficientFundsEvent = null + onBack() + }, + ) + } + + when (val s = state) { + is ConfirmState.Loading -> { + SendConfirmShimmer(onClose = onClose, onBack = onBack) + } + is ConfirmState.Ready -> { + SendConfirmContent( + state = s, + feature = feature, + onClose = onClose, + onBack = onBack, + onSendSuccess = onSendSuccess, + ) + } + is ConfirmState.Error -> { + // TODO + } + } +} + +@Composable +private fun SendConfirmContent( + state: ConfirmState.Ready, + feature: ConfirmFeature, + onClose: () -> Unit, + onSendSuccess: () -> Unit, + onBack: () -> Unit, +) { + val clipboardManager = rememberClipboardManager() + var showFeePopup by remember { mutableStateOf(false) } + var showSendAllConfirmation by remember { mutableStateOf(false) } + val sliderState = rememberSliderState() + val scope = rememberCoroutineScope() + + MoonScaffold( + Modifier + .navigationBarsPadding(), + title = if (state.isExchangeMode) stringResource(Localization.withdraw) else stringResource(Localization.send), + onClose = onClose, + onBack = onBack, + ) { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .nestedScroll(rememberNestedScrollInteropConnection()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val context = LocalContext.current + + when { + state.exchangeData != null && state.token != null -> { + HeaderCell( + from = state.token.asCurrency, + to = state.exchangeData.currency, + ) + } + state.token != null -> { + val chainIcon = remember(state.token) { + if (state.token.tokenType != null) state.token.asCurrency.chain.iconExternalUrl(context) else null + } + + MoonCutBadgedBox( + badge = { + chainIcon?.let { + MoonItemImage( + image = chainIcon, + size = 28.dp + ) + } + }, + direction = BadgeDirection.EndBottom, + ) { + MoonItemImage( + image = state.token.imageUri.toString(), + size = 96.dp, + ) + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(Localization.jetton_transfer, state.token.symbol), + color = UIKit.colorScheme.text.secondary, + style = UIKit.typography.body1, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + MoonBundleCell { + Column { + MoonPropertyBigCell( + title = stringResource(Localization.wallet), + value = state.walletName, + valueDescription = null, + onClick = { clipboardManager.copy(state.walletName) } + ) + + MoonItemDivider() + + if (!state.isExchangeMode && state.recipientDisplay != null) { + TextCell( + title = { MoonItemTitle(stringResource(Localization.recipient), color = UIKit.colorScheme.text.secondary) }, + subtitle = { + MoonItemTitle( + text = state.recipientDisplay, + maxLines = 2, + ) + }, + minHeight = 82.dp, + onClick = { clipboardManager.copy(state.recipientDisplay) } + ) + + MoonItemDivider() + + TextCell( + title = { MoonItemTitle(stringResource(Localization.recipient_address), color = UIKit.colorScheme.text.secondary) }, + subtitle = { + MoonItemTitle( + text = state.recipientAddress, + maxLines = 2, + ) + }, + minHeight = 82.dp, + onClick = { clipboardManager.copy(state.recipientAddress) } + ) + } else { + val address = state.recipientDisplay ?: state.recipientAddress + TextCell( + title = { MoonItemTitle(stringResource(Localization.recipient), color = UIKit.colorScheme.text.secondary) }, + subtitle = { + MoonItemTitle( + text = address, + maxLines = 2, + ) + }, + minHeight = 82.dp, + onClick = { clipboardManager.copy(address) } + ) + } + + state.displayCurrency?.let { currency -> + MoonItemDivider() + + MoonPropertyBigCell( + title = stringResource(Localization.network), + value = currency.chain.name, + valueDescription = currency.tokenType?.fmt, + ) + } + + state.withdrawalFeeFormatted?.let { fee -> + MoonItemDivider() + + MoonPropertyBigCell( + title = stringResource(Localization.ramp_withdrawal_fee), + value = fee, + valueDescription = state.withdrawalFeeFiatFormatted, + onClick = { clipboardManager.copy(state.withdrawalFeeFiatFormatted.toString()) } + ) + } + + MoonItemDivider() + + Box { + MoonPropertyBigCell( + title = { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + MoonItemTitle( + modifier = Modifier, + text = stringResource(Localization.network_fee), + color = UIKit.colorScheme.text.secondary, + ) + + if (state.hasMultipleFeeOptions) { + MoonItemSubtitle( + text = stringResource(Localization.edit_full), + color = UIKit.colorScheme.text.accent, + ) + } + } + }, + content = { + MoonItemTitle(text = state.feeFormatted) + + DropdownMenu( + containerColor = UIKit.colorScheme.background.contentTint, + shape = UIKit.shapes.large, + expanded = showFeePopup, + onDismissRequest = { showFeePopup = false }, + ) { + Column { + state.feeOptions.forEach { fee -> + FeeItemCell( + image = { FeeIcon(fee) }, + title = feeTitle(fee), + subtitle = feeSubtitle(fee), + isChecked = fee == state.selectedFee, + onClick = { + showFeePopup = false + feature.sendAction(ConfirmAction.SelectFee(fee)) + }, + ) + } + } + } + }, + contentDescription = { MoonItemSubtitle(text = state.feeFiatFormatted) }, + onClick = if (state.hasMultipleFeeOptions) { + { showFeePopup = true } + } else { + null + }, + ) + } + + state.totalFormatted?.let { total -> + MoonItemDivider() + + MoonPropertyBigCell( + title = stringResource(Localization.ramp_total_amount), + value = total, + valueDescription = state.totalFiatFormatted, + ) + } + + if (!state.isExchangeMode) { + MoonItemDivider() + + MoonPropertyBigCell( + title = stringResource(Localization.amount), + value = state.amountFormatted, + valueDescription = state.amountFiatFormatted, + onClick = { clipboardManager.copy(state.amountFormatted.toString()) }, + ) + } + + state.estimatedDurationSeconds?.let { duration -> + MoonItemDivider() + + MoonPropertyBigCell( + title = stringResource(Localization.ramp_withdrawal_time), + value = formatDuration(duration), + valueDescription = null, + ) + } + + if (state.comment != null && !state.isExchangeMode) { + MoonItemDivider() + + TextCell( + modifier = Modifier.padding(vertical = 8.dp), + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MoonItemTitle(stringResource(Localization.comment), color = UIKit.colorScheme.text.secondary) + if (state.encryptedComment) { + MoonItemIcon(painterResource(UIKitIcon.ic_lock_16), color = UIKit.colorScheme.accent.green) + } + } + }, + subtitle = { + MoonItemTitle( + text = state.comment, + maxLines = 10, + ) + }, + minHeight = 82.dp, + onClick = { clipboardManager.copy(state.comment) } + ) + } + } + } + + if (state.displayCurrency != null) { + val style = UIKit.typography.body2 + val color = UIKit.colorScheme.text.accent + val termsText = stringResource(Localization.terms_of_service) + val disclaimerTemplate = stringResource(Localization.deposit_changelly_disclaimer, termsText) + MoonDescriptionCell( + text = remember(disclaimerTemplate) { + buildAnnotatedString { + val termsStart = disclaimerTemplate.indexOf(termsText) + if (termsStart >= 0) { + append(disclaimerTemplate.substring(0, termsStart)) + withLink( + text = termsText, + link = Links.ChangellyTerms, + style = style, + color = color, + ) + append(disclaimerTemplate.substring(termsStart + termsText.length)) + } else { + append(disclaimerTemplate) + } + } + } + ) + } + } + + val errorText = when (state.error) { + is ConfirmationError.Unknown -> stringResource(Localization.unknown_error) + is ConfirmationError.InsufficientBalance -> stringResource(Localization.insufficient_balance) + is ConfirmationError.Message -> state.error.text + null -> null + } + + val confirmationState = when (state.signingState) { + SigningState.Idle -> MoonSlideConfirmationState.Slider + SigningState.Loading -> MoonSlideConfirmationState.Loader + SigningState.Success -> MoonSlideConfirmationState.Done + SigningState.Failed -> MoonSlideConfirmationState.Slider + } + + val context = LocalContext.current + + MoonSlideConfirmation( + state = confirmationState, + sliderState = sliderState, + error = errorText, + title = when (confirmationState) { + MoonSlideConfirmationState.Slider, + MoonSlideConfirmationState.Loader -> stringResource(Localization.confirm_action) + + MoonSlideConfirmationState.Done -> stringResource(Localization.done) + }, + enabled = state.signingState == SigningState.Idle, + onConfirm = { + if (state.isMax) { + showSendAllConfirmation = true + } else { + feature.sendAction(ConfirmAction.Sign(context)) + } + }, + onRetry = { feature.sendAction(ConfirmAction.Retry(context)) }, + onDone = { onSendSuccess() }, + ) + + if (showSendAllConfirmation) { + MoonAlertDialog( + message = stringResource(Localization.send_all_balance), + positiveButtonText = stringResource(Localization.continue_action), + negativeButtonText = stringResource(Localization.cancel), + onPositiveClick = { + showSendAllConfirmation = false + feature.sendAction(ConfirmAction.Sign(context)) + }, + onDismiss = { + showSendAllConfirmation = false + scope.launch { sliderState.reset() } + }, + ) + } + } +} + +@Composable +private fun HeaderCell( + from: WalletCurrency, + to: WalletCurrency, +) { + val headerStyle = UIKit.typography.h2 + val headerColor = UIKit.colorScheme.text.primary + val headerSecondaryColor = UIKit.colorScheme.text.secondary + + val fromChainName = from.tokenType?.fmt ?: from.chain.symbol + val toChainName = to.tokenType?.fmt ?: to.chain.symbol + val sendLabel = stringResource( + Localization.send_currency, + from.code, + ) + val receiveLabel = stringResource( + Localization.receive_currency, + to.code, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MoonCutRow { + MoonItemImage(size = 72.dp, image = from.iconUri?.toString()) + MoonItemImage(size = 72.dp, image = to.iconUri?.toString()) + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = stringResource(Localization.confirm_action), + color = UIKit.colorScheme.text.secondary, + style = UIKit.typography.body1, + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = remember(LocalAppColorScheme.current, from, sendLabel) { + buildAnnotatedString { + withStyle(headerStyle.toRichSpanStyle(color = headerColor)) { + append("$sendLabel ") + } + + withStyle(headerStyle.toRichSpanStyle(color = headerSecondaryColor)) { + append(fromChainName) + } + } + }, + textAlign = TextAlign.Center, + ) + + Text( + text = remember(LocalAppColorScheme.current, to, receiveLabel) { + buildAnnotatedString { + withStyle(headerStyle.toRichSpanStyle(color = headerColor)) { + append("$receiveLabel ") + } + + withStyle(headerStyle.toRichSpanStyle(color = headerSecondaryColor)) { + append(toChainName) + } + } + }, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(4.dp)) + } +} + +@Composable +private fun FeeIcon(fee: SendFee) { + when (fee) { + is SendFee.Battery -> MoonItemIcon( + painterResource(UIKitIcon.ic_flash_24), + color = UIKit.colorScheme.accent.green, + ) + is SendFee.Ton, is SendFee.TronTon -> MoonItemImage( + painterResource(UIKitIcon.ic_gram_symbol), + size = 24.dp, + ) + is SendFee.TronTrx -> MoonItemImage( + painterResource(UIKitIcon.ic_tron), + size = 24.dp, + ) + is SendFee.Gasless -> MoonItemIcon( + painterResource(UIKitIcon.ic_flash_24), + color = UIKit.colorScheme.accent.blue, + ) + } +} + +@Composable +private fun feeTitle(fee: SendFee): String { + return when (fee) { + is SendFee.Battery -> stringResource(Localization.battery_refill_title) + is SendFee.Ton -> fee.amount.token.symbol + is SendFee.Gasless -> fee.amount.token.symbol + is SendFee.TronTrx -> fee.amount.token.symbol + is SendFee.TronTon -> "TON" + } +} + +@Composable +private fun feeSubtitle(fee: SendFee): String { + return when (fee) { + is SendFee.TokenFee -> { + when (fee) { + is SendFee.TronTrx if !fee.enoughBalance -> stringResource(Localization.no_enough_funds) + is SendFee.TronTon if !fee.enoughBalance -> stringResource(Localization.no_enough_funds) + else -> { + val formatted = CurrencyFormatter.format(fee.amount.token.symbol, fee.amount.value) + val fiatFormatted = CurrencyFormatter.formatFiat(fee.fiatCurrency.code, fee.fiatAmount) + "≈ $fiatFormatted ($formatted)" + } + } + } + is SendFee.Battery -> { + if (!fee.enoughCharges) { + stringResource(Localization.no_enough_funds) + } else { + val formattedCharges = pluralStringResource( + Plurals.battery_charges, + fee.charges, + CurrencyFormatter.format(value = fee.charges.toBigDecimal()) + ) + + remember(fee) { + val formattedFiat = CurrencyFormatter.formatFiat(fee.fiatCurrency.code, fee.fiatAmount) + "≈ $formattedFiat ($formattedCharges)" + } + } + } + } +} + +// TODO remove this +@Composable +private fun SendConfirmShimmer( + onClose: () -> Unit, + onBack: () -> Unit, +) { + val shimmer by rememberShimmerPhase() + + MoonScaffold( + Modifier + .fillMaxWidth() + .navigationBarsPadding(), + title = stringResource(Localization.send), + onClose = onClose, + onBack = onBack, + ) { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .nestedScroll(rememberNestedScrollInteropConnection()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MoonLoadingPreviewImage(size = 96.dp) + + Spacer(Modifier.height(16.dp)) + + Spacer( + Modifier + .fillMaxWidth(0.4f) + .height(20.dp) + .shimmer(shimmer) + ) + + Spacer(Modifier.height(4.dp)) + + Spacer( + Modifier + .fillMaxWidth(0.5f) + .height(28.dp) + .shimmer(shimmer) + ) + + Spacer(Modifier.height(16.dp)) + + MoonBundleCell { + Column { + MoonPropertyBigCell( + title = { + Spacer( + Modifier + .fillMaxWidth(0.25f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + content = { + Spacer( + Modifier + .fillMaxWidth(0.3f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + ) + + MoonItemDivider() + + MoonPropertyBigCell( + title = { + Spacer( + Modifier + .fillMaxWidth(0.25f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + content = { + Spacer( + Modifier + .fillMaxWidth(0.5f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + ) + + MoonItemDivider() + + MoonPropertyBigCell( + title = { + Spacer( + Modifier + .fillMaxWidth(0.25f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + content = { + Spacer( + Modifier + .fillMaxWidth(0.3f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + contentDescription = { + Spacer( + Modifier + .fillMaxWidth(0.25f) + .height(16.dp) + .shimmer(shimmer) + ) + }, + ) + + MoonItemDivider() + + MoonPropertyBigCell( + title = { + Spacer( + Modifier + .fillMaxWidth(0.25f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + content = { + Spacer( + Modifier + .fillMaxWidth(0.3f) + .height(20.dp) + .shimmer(shimmer) + ) + }, + contentDescription = { + Spacer( + Modifier + .fillMaxWidth(0.25f) + .height(16.dp) + .shimmer(shimmer) + ) + }, + ) + } + } + } + + MoonSlideConfirmation( + state = MoonSlideConfirmationState.Slider, + modifier = Modifier.shimmer(shimmer), + title = stringResource(Localization.confirm_action), + enabled = false, + onConfirm = {}, + onDone = {}, + ) + } +} + +// TODO to design system +@Composable +fun MoonPropertyBigCell( + title: CharSequence, + value: CharSequence, + valueDescription: CharSequence?, + onClick: (() -> Unit)? = null, +) { + MoonPropertyBigCell( + title = { + MoonItemTitle( + modifier = Modifier, + text = title, + color = UIKit.colorScheme.text.secondary, + ) + }, + content = { + MoonItemTitle(text = value) + }, + contentDescription = valueDescription?.let { + { MoonItemSubtitle(text = it) } + }, + onClick = onClick, + ) +} + +// TODO to design system +@Composable +fun MoonPropertyBigCell( + title: @Composable () -> Unit, + content: @Composable () -> Unit, + contentDescription: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + Row( + modifier = Modifier + .padding(16.dp) + .modifyIf { onClick?.let { clickable(onClick = it) } }, + ) { + title() + + Spacer(Modifier.weight(1f)) + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + content() + + contentDescription?.let { + contentDescription.invoke() + } + } + } +} + + +@Preview +@Composable +private fun SendConfirmScreenPreview() { + ThemedPreview { + MoonSurface { + Column(Modifier.fillMaxSize()) { + DropdownMenu( + modifier = Modifier.background(color = UIKit.colorScheme.background.content), + containerColor = UIKit.colorScheme.background.contentTint, + shape = UIKit.shapes.large, + expanded = true, + onDismissRequest = { }, + ) { + Column { + FeeItemCell( + image = { MoonItemIcon(painterResource(UIKitIcon.ic_flash_24), color = UIKit.colorScheme.accent.green) }, + title = "Tonkeeper Battery", + subtitle = "≈ 0.13 - 0.27 TON $0.07", + isChecked = false, + onClick = { }, + ) + + FeeItemCell( + image = { MoonItemImage(painterResource(UIKitIcon.ic_ton), size = 24.dp) }, + title = "TON", + subtitle = "≈ 0.13 - 0.27 TON $0.07", + isChecked = true, + onClick = { }, + ) + } + } + } + } + } +} + +@Preview +@Composable +private fun ExpandendScreen() { + ThemedPreview { + Column( + Modifier.fillMaxSize() + ) { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + } + + MoonSlideConfirmation( + state = MoonSlideConfirmationState.Slider, + title = stringResource(Localization.confirm_action), + enabled = false, + onConfirm = {}, + onDone = {}, + ) + } + } +} + diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/confirmComponents.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/confirmComponents.kt new file mode 100644 index 000000000..9633ea9e1 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/confirm/confirmComponents.kt @@ -0,0 +1,52 @@ +package com.tonapps.deposit.screens.confirm + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.tonapps.uikit.icon.UIKitIcon +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemSubtitle +import ui.components.moon.MoonItemTitle +import ui.theme.UIKit + +@Composable +internal fun FeeItemCell( + image: @Composable () -> Unit, + title: String, + subtitle: String, + isChecked: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + image() + + Column( + modifier = Modifier.width(200.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + MoonItemTitle(title) + MoonItemSubtitle(subtitle) + } + + MoonItemIcon( + modifier = Modifier.alpha(if (isChecked) 1f else 0f), + painter = painterResource(UIKitIcon.ic_done_16), + color = UIKit.colorScheme.accent.blue, + ) + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/currency/SelectCurrencyFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/currency/SelectCurrencyFeature.kt new file mode 100644 index 000000000..9b8365dde --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/currency/SelectCurrencyFeature.kt @@ -0,0 +1,57 @@ +package com.tonapps.deposit.screens.currency + +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.settings.SettingsRepository + +sealed interface SelectCurrencyAction : MviAction { + data object Init : SelectCurrencyAction +} + +sealed interface SelectCurrencyState : MviState { + data object Loading : SelectCurrencyState + data object Empty : SelectCurrencyState + data class Data(val currencies: List) : SelectCurrencyState +} + +class SelectCurrencyViewState( + val global: MviProperty +) : MviViewState + +class SelectCurrencyFeature( + private val accountRepository: AccountRepository, + private val exchangeRepository: ExchangeRepository, + private val settingsRepository: SettingsRepository, +) : MviFeature( + initState = SelectCurrencyState.Loading, + initAction = SelectCurrencyAction.Init, +) { + + override fun createViewState(): SelectCurrencyViewState { + return buildViewState { + SelectCurrencyViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: SelectCurrencyAction) { + when (action) { + SelectCurrencyAction.Init -> { + val wallet = accountRepository.forceSelectedWallet() + val currencies = + exchangeRepository.getCurrencies(wallet.network, settingsRepository.getLocale()) + + if (currencies.isEmpty()) { + setState { SelectCurrencyState.Empty } + } else { + setState { SelectCurrencyState.Data(currencies) } + } + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/currency/SelectCurrencyScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/currency/SelectCurrencyScreen.kt new file mode 100644 index 000000000..80f323585 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/currency/SelectCurrencyScreen.kt @@ -0,0 +1,125 @@ +package com.tonapps.deposit.screens.currency + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.deposit.components.VerticalAssetCell +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonItemDivider +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonLoaderCell +import ui.components.moon.cell.MoonSearchCell +import ui.components.moon.cell.defaultBundleType +import ui.components.moon.container.MoonScaffold +import ui.components.moon.container.MoonSurface +import ui.components.moon.screen.MoonEmptyScreen +import ui.workaround.hideKeyboardOnScrollConnection + +@Composable +fun SelectCurrencyScreen( + feature: SelectCurrencyFeature, + selectedCurrencyCode: String?, + onConfirm: (WalletCurrency) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val state by feature.state.global.observeSafeState() + + when (val s = state) { + is SelectCurrencyState.Loading -> MoonSurface { MoonLoaderCell() } + is SelectCurrencyState.Empty -> MoonEmptyScreen( + text = stringResource(Localization.cant_find_anything), + ) + + is SelectCurrencyState.Data -> { + SelectCurrencyContent( + currencies = s.currencies, + selectedCurrencyCode = selectedCurrencyCode, + onConfirm = onConfirm, + onBack = onBack, + onClose = onClose, + ) + } + } +} + +@Composable +private fun SelectCurrencyContent( + currencies: List, + selectedCurrencyCode: String?, + onConfirm: (WalletCurrency) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val searchText = remember { mutableStateOf("") } + val filteredCurrencies: List by remember(currencies) { + derivedStateOf { + val query = searchText.value.trim() + if (query.isEmpty()) { + currencies + } else { + currencies.filter { it.containsQuery(query) } + } + } + } + + MoonScaffold( + title = stringResource(Localization.choose_currency), + onClose = onClose, + onBack = onBack, + ) { + MoonSearchCell( + searchText = searchText, + onChanged = { searchText.value = it }, + isFocusOnStart = true, + error = false, + ) + Spacer(Modifier.height(8.dp)) + + if (filteredCurrencies.isEmpty()) { + MoonEmptyScreen( + text = stringResource(Localization.cant_find_anything), + ) + } else { + LazyColumn( + modifier = Modifier.nestedScroll(hideKeyboardOnScrollConnection()), + contentPadding = remember { PaddingValues(bottom = 32.dp) } + ) { + itemsIndexed( + items = filteredCurrencies, + key = { _, currency -> currency.key }, + contentType = { _, currency -> currency::class } + ) { index, currency -> + MoonBundleCell( + position = defaultBundleType(filteredCurrencies.size, index) + ) { + if (index > 0) MoonItemDivider() + val isChecked = currency.code == selectedCurrencyCode + VerticalAssetCell( + currency = currency, + isSelected = isChecked, + onClick = { + onConfirm(currency) + onBack() + } + ) + } + } + } + } + } +} + diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/PaymentMethodFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/PaymentMethodFeature.kt new file mode 100644 index 000000000..e8345c7af --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/PaymentMethodFeature.kt @@ -0,0 +1,359 @@ +package com.tonapps.deposit.screens.method + +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events.DepositFlow.DepositFlowSellAsset +import com.tonapps.bus.generated.Events.WithdrawFlow.WithdrawFlowBuyAsset +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.deposit.data.assetsOfType +import com.tonapps.deposit.data.pairedCryptoNetworkInfos +import com.tonapps.deposit.data.resolveAssetFromDeeplink +import com.tonapps.deposit.screens.network.CryptoNetworkInfo +import com.tonapps.deposit.screens.ramp.RampType +import com.tonapps.deposit.toBuyAsset +import com.tonapps.deposit.toSellAsset +import com.tonapps.extensions.lazyUnsafe +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import io.exchangeapi.models.CreateP2PSessionRequest +import io.exchangeapi.models.ExchangeLayout +import io.exchangeapi.models.ExchangeLayoutItemType +import io.exchangeapi.models.ExchangeMerchantInfo + +sealed interface PaymentMethodAction : MviAction { + data object Init : PaymentMethodAction + data class SelectCurrency(val currency: WalletCurrency) : PaymentMethodAction + data object CheckP2PMethod : PaymentMethodAction + data class AllowP2P(val isDontShowAgain: Boolean) : PaymentMethodAction +} + +sealed interface PaymentMethodEvent { + data object ShowP2PError : PaymentMethodEvent + data class ShowP2PConfirmation(val merchant: ExchangeMerchantInfo) : PaymentMethodEvent + data class OpenP2P(val url: String) : PaymentMethodEvent + data class AutoNavigateCrypto( + val resolvedAsset: RampAsset, + val targetCurrency: WalletCurrency + ) : PaymentMethodEvent + + data class AutoNavigateCash( + val resolvedAsset: RampAsset, + val paymentMethodType: String, + val fiatCurrency: WalletCurrency + ) : PaymentMethodEvent +} + +sealed interface PaymentMethodState : MviState { + data object Loading : PaymentMethodState + data object Empty : PaymentMethodState + + data class Data( + val asset: RampAsset, + val currencies: List, + val selectedCurrency: WalletCurrency?, + val paymentMethods: List, + val cryptoAssets: List, + val stablecoinAssets: List, + val stablecoinNetworks: Map> = emptyMap(), + val sectionFilter: PaymentMethodSectionFilter = PaymentMethodSectionFilter.All, + ) : PaymentMethodState { + val showCashSection: Boolean + get() = paymentMethods.isNotEmpty() + && (sectionFilter == PaymentMethodSectionFilter.All + || sectionFilter == PaymentMethodSectionFilter.CashOnly) + val showCryptoSection: Boolean + get() = cryptoAssets.isNotEmpty() + && (sectionFilter == PaymentMethodSectionFilter.All + || sectionFilter == PaymentMethodSectionFilter.CryptoOnly) + val showStablecoinSection: Boolean + get() = stablecoinAssets.isNotEmpty() + && (sectionFilter == PaymentMethodSectionFilter.All + || sectionFilter == PaymentMethodSectionFilter.StablecoinOnly) + } +} + +data class PaymentMethodItem( + val type: String, + val title: String, + val subtitle: String?, + val iconUrl: String, +) { + val isP2P: Boolean by lazyUnsafe { + type.equals("p2p", true) + } +} + +class PaymentMethodViewState( + val global: MviProperty +) : MviViewState + +@kotlinx.serialization.Serializable +enum class PaymentMethodSectionFilter { + All, CashOnly, CryptoOnly, StablecoinOnly +} + +data class PaymentMethodFeatureData( + val initialAsset: RampAsset? = null, + val rampType: RampType, + val ft: String? = null, + val tn: String? = null, + val tt: String? = null, + val fn: String? = null, + val cm: String? = null, + val sectionFilter: PaymentMethodSectionFilter = PaymentMethodSectionFilter.All, + val preferredCurrency: String? = null, +) + +class PaymentMethodFeature( + private val data: PaymentMethodFeatureData, + private val exchangeRepository: ExchangeRepository, + private val settingsRepository: SettingsRepository, + private val accountRepository: AccountRepository, + private val api: API, +) : MviFeature( + initState = PaymentMethodState.Loading, + initAction = PaymentMethodAction.Init +) { + val rampType: RampType get() = data.rampType + + private lateinit var asset: RampAsset + + /** Resolved asset, available after loadData completes. Used by router for manual navigation. */ + val resolvedAsset: RampAsset? get() = if (::asset.isInitialized) asset else null + + init { + if (data.initialAsset != null) asset = data.initialAsset + } + + private val relay = MviRelay() + val events = relay.events + + override fun createViewState(): PaymentMethodViewState { + return buildViewState { + PaymentMethodViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: PaymentMethodAction) { + when (action) { + is PaymentMethodAction.Init -> loadData() + is PaymentMethodAction.SelectCurrency -> applyCurrencySelection(action.currency) + is PaymentMethodAction.CheckP2PMethod -> { + trackP2PView() + val wallet = accountRepository.getSelectedWallet() ?: return + val shouldValidated = + settingsRepository.isPurchaseOpenConfirm(wallet.id, P2P_OPEN_CONFIRM_ID) + if (shouldValidated) { + val merchant = exchangeRepository.getMerchants() + .firstOrNull { it.id == WALLET_MERCHANT_ID } + if (merchant != null) { + relay.emit(PaymentMethodEvent.ShowP2PConfirmation(merchant)) + return + } + } + + try { + val url = createP2PSession(wallet.address) + relay.emit(PaymentMethodEvent.OpenP2P(url)) + } catch (e: Exception) { + relay.emit(PaymentMethodEvent.ShowP2PError) + L.e(e) + } + } + + is PaymentMethodAction.AllowP2P -> { + val wallet = accountRepository.getSelectedWallet() ?: return + if (action.isDontShowAgain) { + settingsRepository.disablePurchaseOpenConfirm(wallet.id, P2P_OPEN_CONFIRM_ID) + } + try { + val url = createP2PSession(wallet.address) + relay.emit(PaymentMethodEvent.OpenP2P(url)) + } catch (e: Exception) { + relay.emit(PaymentMethodEvent.ShowP2PError) + L.e(e) + } + } + } + } + + fun selectCurrency(currency: WalletCurrency) { + sendAction(PaymentMethodAction.SelectCurrency(currency)) + } + + private suspend fun loadData() { + setState { PaymentMethodState.Loading } + try { + val wallet = accountRepository.forceSelectedWallet() + val preferredCode = data.preferredCurrency + val fiatCode = settingsRepository.currency.code.uppercase() + val currencies = + exchangeRepository.getCurrencies(wallet.network, settingsRepository.getLocale()) + + val preferredCurrency = if (preferredCode != null) { + currencies.find { it.code.equals(preferredCode, true) } + } else { + null + } + val selectedCurrency = preferredCurrency + ?: currencies.find { it.code.equals(fiatCode, true) } + ?: currencies.firstOrNull() + + val layout = exchangeRepository.getLayoutCurrency(rampType, selectedCurrency) + + if (!::asset.isInitialized) { + val currency = layout.resolveAssetFromDeeplink(data.ft, data.tn) + ?: throw IllegalStateException("Cannot resolve asset from deeplink params ft=${data.ft} tn=${data.tn}") + asset = RampAsset.Currency(currency) + } + + val (paymentMethods, crypto, stablecoins, networks) = resolveLayout(layout) + + setState { + PaymentMethodState.Data( + asset = asset, + currencies = currencies, + selectedCurrency = selectedCurrency, + paymentMethods = paymentMethods, + cryptoAssets = crypto, + stablecoinAssets = stablecoins, + stablecoinNetworks = networks, + sectionFilter = data.sectionFilter, + ) + } + + emitDeeplinkNavigation(layout, selectedCurrency) + } catch (e: Throwable) { + L.e(e) + setState { PaymentMethodState.Empty } + } + } + + private suspend fun emitDeeplinkNavigation( + layout: ExchangeLayout, + fiatCurrency: WalletCurrency? + ) { + if (data.tt != null) { + val targetCurrency = layout.resolveAssetFromDeeplink(data.tt, data.fn) ?: return + relay.emit(PaymentMethodEvent.AutoNavigateCrypto(asset, targetCurrency)) + } else if (data.cm != null && fiatCurrency != null) { + relay.emit(PaymentMethodEvent.AutoNavigateCash(asset, data.cm, fiatCurrency)) + } + } + + private suspend fun createP2PSession(address: String): String { + val state = obtainSpecificState() + val fiatCurrency = state?.selectedCurrency?.code ?: error("Currency must be specified") + val request = CreateP2PSessionRequest( + wallet = address, + network = asset.network ?: "NATIVE", + cryptoCurrency = asset.currencyCode, + fiatCurrency = fiatCurrency, + ) + val response = exchangeRepository.createP2PSession(request) + return response.deeplinkUrl + } + + private suspend fun applyCurrencySelection(currency: WalletCurrency) { + val prevState = obtainSpecificState() ?: return + setState { PaymentMethodState.Loading } + try { + exchangeRepository.clearRampCache(rampType) + val layout = exchangeRepository.getLayoutCurrency(rampType, currency) + val (paymentMethods, crypto, stablecoins, networks) = resolveLayout(layout) + + setState { + prevState.copy( + selectedCurrency = currency, + paymentMethods = paymentMethods, + cryptoAssets = crypto, + stablecoinAssets = stablecoins, + stablecoinNetworks = networks, + ) + } + } catch (e: Throwable) { + L.e(e) + setState { prevState.copy(selectedCurrency = currency) } + } + } + + private fun resolveLayout(layout: ExchangeLayout): LayoutResult { + val layoutAsset = layout.assetsOfType(ExchangeLayoutItemType.fiat).find { + it.symbol.equals(asset.currencyCode, ignoreCase = true) + && it.network.equals(asset.network ?: "native", ignoreCase = true) + } + + val paymentMethods = layoutAsset?.cashMethods?.map { method -> + PaymentMethodItem( + type = method.type.value, + title = method.name, + subtitle = getSubtitle(method.type.value, api.country), + iconUrl = method.image, + ) + } ?: emptyList() + + val allCrypto = layout.pairedCryptoNetworkInfos(asset.currencyCode, asset.network) + val (crypto, stablecoins, networks) = splitCryptoAssets(allCrypto) + + return if (asset.isStablecoin) { + LayoutResult(paymentMethods, crypto, stablecoins, networks) + } else { + LayoutResult(paymentMethods, allCrypto.map { it.currency }, emptyList(), networks) + } + } + + private data class LayoutResult( + val paymentMethods: List, + val cryptoAssets: List, + val stablecoinAssets: List, + val stablecoinNetworks: Map>, + ) + + private fun splitCryptoAssets( + allCrypto: List + ): Triple, List, Map>> { + val stablecoins = allCrypto.filter { it.currency.isStablecoin } + val crypto = allCrypto.filter { !it.currency.isStablecoin }.map { it.currency } + + val networks = stablecoins.groupBy { it.currency.code.uppercase() } + val grouped = stablecoins.map { it.currency }.distinctBy { it.code.uppercase() } + + return Triple(crypto, grouped, networks) + } + + private fun trackP2PView() { + val events = AnalyticsHelper.Default.events + when (rampType) { + RampType.RampOn -> events.depositFlow.depositViewP2p( + buyAsset = asset.toCurrency.toBuyAsset(), + sellAsset = DepositFlowSellAsset.Fiat, + ) + + RampType.RampOff -> events.withdrawFlow.withdrawViewP2p( + sellAsset = asset.toCurrency.toSellAsset(), + buyAsset = WithdrawFlowBuyAsset.Fiat, + ) + } + } + + companion object { + private val P2P_OPEN_CONFIRM_ID = "p2p" + private val WALLET_MERCHANT_ID = "wallet" + + private fun getSubtitle(type: String, country: String): String? { + if (!type.equals("card", true)) { + return null + } + + return if (country.equals("ru", true)) "МИР" else "Visa, Mastercard" + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/PaymentMethodScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/PaymentMethodScreen.kt new file mode 100644 index 000000000..e8983ba16 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/PaymentMethodScreen.kt @@ -0,0 +1,332 @@ +package com.tonapps.deposit.screens.method + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.core.extensions.iconExternalUrl +import com.tonapps.deposit.components.VerticalAssetCell +import com.tonapps.deposit.screens.provider.ProviderConfirmDialog +import com.tonapps.deposit.screens.ramp.RampType +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import io.exchangeapi.models.ExchangeMerchantInfo +import ui.components.moon.MoonItemDivider +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonSmallItemTitle +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonBundleTitleCell +import ui.components.moon.cell.MoonInfoCell +import ui.components.moon.cell.MoonLoaderCell +import ui.components.moon.container.MoonCutRow +import ui.components.moon.container.MoonScaffold +import ui.components.moon.container.OverlapDirection +import ui.components.moon.screen.MoonEmptyScreen +import ui.painterResource +import ui.theme.UIKit +import ui.utils.toRichSpanStyle +import uikit.navigation.Navigation.Companion.navigation + +internal const val KEY_CURRENCY_SELECTION_RESULT = "currency_selection_result" + + +@Composable +fun PaymentMethodScreen( + feature: PaymentMethodFeature, + fallbackAsset: RampAsset?, + currencySelectionResult: WalletCurrency?, + onBack: () -> Unit, + onClose: () -> Unit, + onPaymentMethodClick: (asset: RampAsset, paymentMethodType: String, fiatCurrency: WalletCurrency) -> Unit, + onBuyWithCrypto: (asset: RampAsset, currency: WalletCurrency) -> Unit, + onSelectCurrency: (selectedCode: String?) -> Unit, + onSelectNetwork: (asset: RampAsset, stablecoinCode: String, fiatCurrency: WalletCurrency?) -> Unit, + onOpenP2P: (url: String) -> Unit, + onAutoNavigateCrypto: ((asset: RampAsset, targetCurrency: WalletCurrency) -> Unit)? = null, + onAutoNavigateCash: ((asset: RampAsset, paymentMethodType: String, fiatCurrency: WalletCurrency) -> Unit)? = null, +) { + val state by feature.state.global.observeSafeState() + val lifecycle = LocalLifecycleOwner.current + val context = LocalContext.current + var confirmationMerchant by remember { mutableStateOf(null) } + + LaunchedEffect(currencySelectionResult) { + if (currencySelectionResult != null) { + feature.selectCurrency(currencySelectionResult) + } + } + + val p2pErrorText = stringResource(Localization.p2p_error) + val toastColor = UIKit.colorScheme.background.contentTint.toArgb() + + LaunchedEffect(Unit) { + feature.events.flowWithLifecycle(lifecycle.lifecycle).collect { event -> + when (event) { + is PaymentMethodEvent.ShowP2PConfirmation -> confirmationMerchant = event.merchant + is PaymentMethodEvent.OpenP2P -> onOpenP2P(event.url) + is PaymentMethodEvent.AutoNavigateCrypto -> onAutoNavigateCrypto?.invoke( + event.resolvedAsset, + event.targetCurrency + ) + + is PaymentMethodEvent.AutoNavigateCash -> onAutoNavigateCash?.invoke( + event.resolvedAsset, + event.paymentMethodType, + event.fiatCurrency + ) + + is PaymentMethodEvent.ShowP2PError -> { + context.navigation?.toast( + message = p2pErrorText, + loading = false, + color = toastColor + ) + } + } + } + } + + confirmationMerchant?.let { merchant -> + val style = UIKit.typography.body1 + val color = UIKit.colorScheme.text.accent + val buttons = merchant.buttons + val description = remember(color, buttons) { + if (buttons.isEmpty()) return@remember null + buildAnnotatedString { + val linkStyle = style.toRichSpanStyle(color = color) + buttons + .filter { it.url.isNotEmpty() } + .forEachIndexed { index, button -> + if (index > 0) append(" · ") + withLink( + LinkAnnotation.Url( + url = button.url, + styles = TextLinkStyles(linkStyle), + ) + ) { append(button.title) } + } + } + } + + ProviderConfirmDialog( + title = merchant.title, + icon = merchant.image, + description = description, + onConfirm = { feature.sendAction(PaymentMethodAction.AllowP2P(it)) }, + onClose = { confirmationMerchant = null }, + ) + } + + val resolveAsset = { feature.resolvedAsset ?: fallbackAsset } + + PaymentMethodContent( + rampType = feature.rampType, + state = state, + onBack = onBack, + onClose = onClose, + onPaymentMethodClick = { type, currency -> + resolveAsset()?.let { onPaymentMethodClick(it, type, currency) } + }, + onP2PMethodClick = { feature.sendAction(PaymentMethodAction.CheckP2PMethod) }, + onBuyWithCrypto = { currency -> + resolveAsset()?.let { onBuyWithCrypto(it, currency) } + }, + onSelectCurrency = onSelectCurrency, + onSelectNetwork = { code, currency -> + resolveAsset()?.let { onSelectNetwork(it, code, currency) } + }, + onRetry = { feature.sendAction(PaymentMethodAction.Init) }, + ) +} + +@Composable +private fun PaymentMethodContent( + rampType: RampType, + state: PaymentMethodState, + onBack: () -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + onPaymentMethodClick: (paymentMethodType: String, fiatCurrency: WalletCurrency) -> Unit, + onP2PMethodClick: () -> Unit, + onBuyWithCrypto: (WalletCurrency) -> Unit, + onSelectCurrency: (selectedCode: String?) -> Unit, + onSelectNetwork: (stablecoinCode: String, fiatCurrency: WalletCurrency?) -> Unit, +) { + MoonScaffold( + title = stringResource(Localization.payment_method), + onClose = onClose, + onBack = onBack, + ) { + when (state) { + is PaymentMethodState.Loading -> MoonLoaderCell() + is PaymentMethodState.Empty -> MoonEmptyScreen( + text = stringResource(Localization.cant_find_anything), + buttonText = stringResource(Localization.retry), + onButtonClick = onRetry, + ) + + is PaymentMethodState.Data -> { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .navigationBarsPadding(), + ) { + if (state.showCashSection) { + val currencyUri = state.selectedCurrency?.iconUri + val currencyCode = state.selectedCurrency?.code + val showCurrencySwitch = state.currencies.size > 1 + + MoonBundleTitleCell( + title = when (rampType) { + RampType.RampOn -> stringResource(Localization.deposit_buy_with_cash) + RampType.RampOff -> stringResource(Localization.ramp_sell_with_cash) + }, + onClick = if (showCurrencySwitch) { + { onSelectCurrency(state.selectedCurrency?.code) } + } else { + null + } + ) { + if (currencyCode != null) { + MoonItemImage(currencyUri?.toString(), size = 16.dp) + MoonSmallItemTitle(text = currencyCode) + if (showCurrencySwitch) { + MoonItemIcon(painter = painterResource(UIKitIcon.ic_switch_16)) + } + } + } + + MoonBundleCell { + Column { + state.paymentMethods.fastForEachIndexed { index, method -> + if (index > 0) MoonItemDivider() + VerticalAssetCell( + name = method.title, + assetImageUrl = method.iconUrl, + extendedName = method.subtitle, + onClick = { + val currency = state.selectedCurrency + ?: state.currencies.firstOrNull() + ?: return@VerticalAssetCell + if (method.isP2P) { + onP2PMethodClick() + } else { + onPaymentMethodClick(method.type, currency) + } + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + if (state.showCryptoSection) { + val cryptoAssets = state.cryptoAssets + + Spacer(modifier = Modifier.height(16.dp)) + MoonInfoCell( + text = stringResource(Localization.ramp_crypto_swap_info) + ) + Spacer(modifier = Modifier.height(16.dp)) + MoonBundleCell { + Column { + cryptoAssets.fastForEachIndexed { index, currency -> + key(currency.key) { + if (index > 0) MoonItemDivider() + VerticalAssetCell(currency) { onBuyWithCrypto(currency) } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + + if (state.showStablecoinSection) { + Spacer(modifier = Modifier.height(16.dp)) + MoonInfoCell( + text = when (rampType) { + RampType.RampOn -> stringResource( + Localization.ramp_stablecoin_buy_info, + state.asset.currencyCode, + state.asset.toCurrency.chain.name + ) + + RampType.RampOff -> stringResource(Localization.ramp_stablecoin_sell_info) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + MoonBundleCell { + Column { + val context = LocalContext.current + state.stablecoinAssets.fastForEachIndexed { index, stablecoin -> + if (index > 0) MoonItemDivider() + val networkIcons = + remember(stablecoin.code, state.stablecoinNetworks) { + state.stablecoinNetworks[stablecoin.code.uppercase()] + ?.mapNotNull { + it.networkImage + ?: it.currency.chain.iconExternalUrl(context) + } + ?.distinct() + ?: emptyList() + } + VerticalAssetCell( + currency = stablecoin, + isAbstract = true, + titleContent = { + if (networkIcons.isNotEmpty()) { + MoonCutRow( + direction = OverlapDirection.StartOnTop, + ) { + networkIcons.forEach { icon -> + MoonItemImage(image = icon, size = 18.dp) + } + } + } + }, + onClick = { + onSelectNetwork( + stablecoin.code, + state.selectedCurrency + ) + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } + } + } +} + diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/RampAsset.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/RampAsset.kt new file mode 100644 index 000000000..38a941ae7 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/method/RampAsset.kt @@ -0,0 +1,32 @@ +package com.tonapps.deposit.screens.method + +import android.net.Uri +import android.os.Parcelable +import com.tonapps.blockchain.model.legacy.WalletCurrency +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +@Parcelize +sealed interface RampAsset : Parcelable { + + val network: String? + val currencyCode: String + val isTon: Boolean + val isStablecoin: Boolean + val isUsdt: Boolean + val imageUrl: Uri? + val toCurrency: WalletCurrency + + @Serializable + @Parcelize + data class Currency(val currency: WalletCurrency) : RampAsset { + override val currencyCode: String get() = currency.code + override val isTon: Boolean get() = false + override val isUsdt: Boolean get() = currency.isUSDT + override val imageUrl: Uri? get() = currency.iconUri + override val network: String? get() = currency.network + override val isStablecoin: Boolean get() = currency.isStablecoin + override val toCurrency: WalletCurrency get() = currency + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/CryptoNetworkInfo.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/CryptoNetworkInfo.kt new file mode 100644 index 000000000..a7563175a --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/CryptoNetworkInfo.kt @@ -0,0 +1,12 @@ +package com.tonapps.deposit.screens.network + +import com.tonapps.blockchain.model.legacy.WalletCurrency + +data class CryptoNetworkInfo( + val currency: WalletCurrency, + val networkImage: String?, + val fee: String?, + val minAmount: String?, + val providerMinAmount: String?, + val providerMaxAmount: String?, +) diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/SelectNetworkFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/SelectNetworkFeature.kt new file mode 100644 index 000000000..c5d342b87 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/SelectNetworkFeature.kt @@ -0,0 +1,65 @@ +package com.tonapps.deposit.screens.network + +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.deposit.data.pairedCryptoNetworkInfos +import com.tonapps.deposit.screens.ramp.RampType +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty + +sealed interface SelectNetworkAction : MviAction { + data object Init : SelectNetworkAction +} + +sealed interface SelectNetworkState : MviState { + data object Loading : SelectNetworkState + data object Empty : SelectNetworkState + data class Data(val networks: List) : SelectNetworkState +} + +class SelectNetworkViewState( + val global: MviProperty +) : MviViewState + +data class SelectNetworkData( + val stablecoinCode: String, + val stablecoinNetwork: String?, + val rampType: RampType, + val selectedSymbol: String? = null, +) + +class SelectNetworkFeature( + val data: SelectNetworkData, + private val exchangeRepository: ExchangeRepository, +) : MviFeature( + initState = SelectNetworkState.Loading, + initAction = SelectNetworkAction.Init, +) { + + override fun createViewState(): SelectNetworkViewState { + return buildViewState { + SelectNetworkViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: SelectNetworkAction) { + when (action) { + SelectNetworkAction.Init -> { + val layout = exchangeRepository.getLayout(data.rampType) + val allNetworks = layout.pairedCryptoNetworkInfos(data.stablecoinCode, data.stablecoinNetwork) + val networks = if (data.selectedSymbol != null) { + allNetworks.filter { it.currency.code.equals(data.selectedSymbol, ignoreCase = true) } + } else { + allNetworks + } + if (networks.isEmpty()) { + setState { SelectNetworkState.Empty } + } else { + setState { SelectNetworkState.Data(networks) } + } + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/SelectNetworkScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/SelectNetworkScreen.kt new file mode 100644 index 000000000..f0c4fe0c3 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/network/SelectNetworkScreen.kt @@ -0,0 +1,116 @@ +package com.tonapps.deposit.screens.network + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import com.tonapps.core.extensions.iconExternalUrl +import com.tonapps.deposit.components.VerticalAssetCell +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonItemDivider +import ui.components.moon.MoonLargeItemSubtitle +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonDescriptionCell +import ui.components.moon.cell.MoonInfoCell +import ui.components.moon.cell.MoonLoaderCell +import ui.components.moon.container.MoonScaffold +import ui.components.moon.container.MoonSurface + +@Composable +fun SelectNetworkScreen( + feature: SelectNetworkFeature, + onSelect: (CryptoNetworkInfo) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, + showFee: Boolean = false, +) { + val state by feature.state.global.observeSafeState() + + when (val s = state) { + SelectNetworkState.Loading -> MoonSurface { MoonLoaderCell() } + SelectNetworkState.Empty -> onBack() + is SelectNetworkState.Data -> { + SelectNetworkContent( + networks = s.networks, + stablecoinCode = feature.data.selectedSymbol ?: feature.data.stablecoinCode, + showFee = showFee, + onSelect = onSelect, + onBack = onBack, + onClose = onClose, + ) + } + } +} + +@Composable +private fun SelectNetworkContent( + stablecoinCode: String, + networks: List, + showFee: Boolean, + onSelect: (CryptoNetworkInfo) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, +) { + MoonScaffold( + Modifier + .verticalScroll(rememberScrollState()) + .nestedScroll(rememberNestedScrollInteropConnection()), + title = stringResource(Localization.choose_network_error, stablecoinCode), + onClose = onClose, + onBack = onBack, + ) { + MoonInfoCell( + text = stringResource(Localization.deposit_network_warning) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + MoonBundleCell { + Column { + val context = LocalContext.current + networks.fastForEachIndexed { index, networkInfo -> + val currency = networkInfo.currency + key(currency.code) { + val icon = remember { + networkInfo.networkImage + ?: currency.chain.iconExternalUrl(context) + ?: currency.iconUri?.toString() + } + + if (index > 0) MoonItemDivider() + + VerticalAssetCell( + name = currency.title.ifBlank { currency.chain.name }, + assetImageUrl = icon, + extendedName = currency.chain.name, + onClick = { onSelect(networkInfo) }, + content = if (showFee) { + { + val fee = networkInfo.fee ?: "0" + MoonLargeItemSubtitle(text = remember { "≈ $fee ${currency.symbol}" }) + } + } else null + ) + } + } + } + } + + MoonDescriptionCell( + text = stringResource(Localization.deposit_select_network_description, stablecoinCode) + ) + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/picker/SelectTokenFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/picker/SelectTokenFeature.kt new file mode 100644 index 000000000..f8a35b682 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/picker/SelectTokenFeature.kt @@ -0,0 +1,54 @@ +package com.tonapps.deposit.screens.picker + +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.token.TokenRepository +import com.tonapps.wallet.data.token.entities.AccountTokenEntity + +sealed interface TokenPickerAction : MviAction { + data object Init : TokenPickerAction +} + +sealed interface TokenPickerState : MviState { + data object Loading : TokenPickerState + data class Data(val tokens: List) : TokenPickerState +} + +class TokenPickerViewState( + val global: MviProperty +) : MviViewState + +class TokenPickerFeature( + private val accountRepository: AccountRepository, + private val settingsRepository: SettingsRepository, + private val tokenRepository: TokenRepository, +) : MviFeature( + initState = TokenPickerState.Loading, + initAction = TokenPickerAction.Init, +) { + + override fun createViewState(): TokenPickerViewState { + return buildViewState { + TokenPickerViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: TokenPickerAction) { + when (action) { + TokenPickerAction.Init -> { + val wallet = accountRepository.forceSelectedWallet() + val currency = settingsRepository.currency + val tokens = tokenRepository.get(currency, wallet.accountId, wallet.network) + ?.filter { it.balance.isTransferable && !it.isTrx } + ?: emptyList() + + setState { TokenPickerState.Data(tokens) } + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/picker/SelectTokenScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/picker/SelectTokenScreen.kt new file mode 100644 index 000000000..a4578bdd2 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/picker/SelectTokenScreen.kt @@ -0,0 +1,122 @@ +package com.tonapps.deposit.screens.picker + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tonapps.deposit.components.AssetCell +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.wallet.data.token.entities.AccountTokenEntity +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonItemDivider +import ui.components.moon.cell.MoonLoaderCell +import ui.components.moon.cell.MoonSearchCell +import ui.components.moon.cell.defaultBundleType +import ui.components.moon.container.MoonScaffold +import ui.components.moon.container.MoonSurface +import ui.components.moon.screen.MoonEmptyScreen + +@Composable +fun TokenPickerScreen( + feature: TokenPickerFeature, + selectedTokenAddress: String?, + onTokenSelected: (AccountTokenEntity) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val state by feature.state.global.observeSafeState() + + when (val s = state) { + is TokenPickerState.Loading -> MoonSurface { MoonLoaderCell() } + is TokenPickerState.Data -> { + TokenPickerContent( + tokens = s.tokens, + selectedTokenAddress = selectedTokenAddress, + onTokenSelected = onTokenSelected, + onBack = onBack, + onClose = onClose, + ) + } + } +} + +@Composable +private fun TokenPickerContent( + tokens: List, + selectedTokenAddress: String?, + onTokenSelected: (AccountTokenEntity) -> Unit, + onBack: () -> Unit, + onClose: () -> Unit, +) { + val searchText = remember { mutableStateOf("") } + + val filteredTokens by remember(tokens) { + derivedStateOf { + if (searchText.value.isBlank()) { + tokens + } else { + tokens.filter { + it.symbol.contains(searchText.value, ignoreCase = true) || + it.name.contains(searchText.value, ignoreCase = true) + } + } + } + } + + MoonScaffold( + title = stringResource(Localization.choose_token), + onClose = onClose, + onBack = onBack, + ) { + MoonSearchCell( + searchText = searchText, + onChanged = { searchText.value = it }, + error = false, + ) + Spacer(Modifier.height(8.dp)) + + if (filteredTokens.isEmpty()) { + MoonEmptyScreen( + text = stringResource(Localization.cant_find_anything), + ) + } else { + LazyColumn( + contentPadding = remember { PaddingValues(bottom = 32.dp) } + ) { + itemsIndexed( + items = filteredTokens, + key = { _, token -> token.address }, + contentType = { _, token -> token::class } + ) { index, token -> + if (index > 0) MoonItemDivider() + + val balance = remember(token.token) { + CurrencyFormatter.format(token.symbol.take(6), token.balance.uiBalance) + .toString() + } + + AssetCell( + position = defaultBundleType(filteredTokens.size, index), + entity = token.token, + description = balance, + isSelected = token.address == selectedTokenAddress, + onClick = { + onTokenSelected(token) + onBack() + }, + ) + } + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/ProviderConfirmDialog.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/ProviderConfirmDialog.kt new file mode 100644 index 000000000..1db0300dc --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/ProviderConfirmDialog.kt @@ -0,0 +1,155 @@ +package com.tonapps.deposit.screens.provider + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonAsyncImage +import ui.components.moon.MoonTopAppBarSimple +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.cell.MoonInfoCell +import ui.components.moon.cell.MoonTextCheckboxCell +import ui.components.moon.cell.MoonTextContentCell +import ui.components.moon.dialog.MoonModalDialog +import ui.components.moon.dialog.rememberDialogNavigator +import ui.preview.ThemedPreview +import ui.theme.Dimens +import ui.theme.UIKit + +@Preview +@Composable +private fun ProviderConfirmDialogPreview() { + ThemedPreview { + ProviderConfirmDialog( + title = "Moonpay", + icon = "", + description = null, + onConfirm = {}, + onClose = {}, + ) + } +} + +@Composable +fun ProviderConfirmDialog( + title: String, + icon: Any, + description: CharSequence? = null, + onConfirm: (doNotShowAgain: Boolean) -> Unit, + onClose: () -> Unit, +) { + val navigator = rememberDialogNavigator(onClose = onClose) + MoonModalDialog(navigator = navigator) { + MoonTopAppBarSimple( + title = "", + actionIconRes = UIKitIcon.ic_close_16, + onActionClick = { navigator.close() }, + backgroundColor = Color.Transparent, + ) + ProviderConfirmDialogContent( + title = title, + icon = icon, + description = description, + onConfirm = onConfirm, + ) + } +} + +@Composable +private fun ProviderConfirmDialogContent( + title: String, + icon: Any, + description: CharSequence?, + onConfirm: (doNotShowAgain: Boolean) -> Unit, +) { + var isChecked by remember { mutableStateOf(false) } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DAppIcon(icon = icon) + Spacer(modifier = Modifier.height(Dimens.offsetMedium)) + + MoonTextContentCell( + title = title, + description = description, + ) + + Spacer(modifier = Modifier.height(Dimens.offsetLarge)) + + MoonInfoCell( + text = stringResource(Localization.fiat_open_description), + painter = painterResource(UIKitIcon.ic_exclamationmark_circle_16), + ) + + MoonButtonCell( + onClick = { onConfirm(isChecked) }, + text = stringResource(Localization.open), + ) + + MoonTextCheckboxCell( + text = stringResource(Localization.do_not_show_again), + isChecked = isChecked, + onCheckedChanged = { isChecked = it }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } +} + +// TODO to design system +@Composable +private fun DAppIcon(icon: Any?) { + Box(modifier = Modifier.padding(Dimens.offsetMedium)) { + Box( + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(24.dp)) + .background(UIKit.colorScheme.background.content), + contentAlignment = Alignment.Center, + ) { + if (icon != null) { + if (icon is Painter) { + Image( + painter = icon, + contentDescription = null, + ) + } else { + MoonAsyncImage( + image = icon, + size = 72.dp, + ) + } + } else { + Image( + painter = painterResource(UIKitIcon.ic_globe_56), + contentDescription = null, + colorFilter = ColorFilter.tint(UIKit.colorScheme.icon.secondary), + ) + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/ProviderItem.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/ProviderItem.kt new file mode 100644 index 000000000..88de262ab --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/ProviderItem.kt @@ -0,0 +1,42 @@ +package com.tonapps.deposit.screens.provider + +import androidx.compose.runtime.Stable +import com.tonapps.icu.Coins +import io.exchangeapi.models.ExchangeMerchantInfoButton + +@Stable +data class ProviderItem( + val id: String, + val title: String, + val imageUrl: String, + val isBest: Boolean, + val minAmount: Coins = Coins.ZERO, + val maxAmount: Coins? = null, + val buttons: List = emptyList(), +) { + companion object { + val EMPTY = ProviderItem(id = "", title = "", imageUrl = "", isBest = false) + } +} + +@Stable +data class ProviderRate( + val rateFormatted: String, +) + +@Stable +data class ProviderQuote( + val amount: Coins, + val receiveCoins: Coins, + val currencyCode: String, + val widgetUrl: String, + val receiveAmount: String? = null, + val merchantTransactionId: String? = null, +) + +@Stable +data class ProviderWithQuote( + val info: ProviderItem, + val rate: ProviderRate? = null, + val quote: ProviderQuote? = null, +) diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/SelectProviderDialog.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/SelectProviderDialog.kt new file mode 100644 index 000000000..901a52c9c --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/provider/SelectProviderDialog.kt @@ -0,0 +1,134 @@ +package com.tonapps.deposit.screens.provider + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.tonapps.icu.Coins +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonItemDivider +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonItemSubtitle +import ui.components.moon.MoonLabel +import ui.components.moon.MoonLabelDefault +import ui.components.moon.MoonTopAppBarSimple +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonDescriptionCell +import ui.components.moon.cell.TextCheckCell +import ui.components.moon.container.MoonSurface +import ui.components.moon.dialog.MoonModalDialog +import ui.components.moon.dialog.rememberDialogNavigator +import ui.theme.UIKit + +@Composable +fun SelectProviderDialog( + providers: List, + selectedProviderId: String?, + currencyCode: String, + onConfirm: (providerId: String, minAmount: Coins?) -> Unit, + onClose: () -> Unit, +) { + var localSelectedId by remember { mutableStateOf(selectedProviderId) } + val navigator = rememberDialogNavigator(onClose = onClose) + + MoonModalDialog(navigator = navigator) { + MoonTopAppBarSimple( + title = stringResource(Localization.provider), + navigationIconRes = UIKitIcon.ic_chevron_down_16, + onNavigationClick = { navigator.close() }, + ) + + MoonSurface { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + ) { + MoonBundleCell { + Column { + val sorted = remember(providers) { + providers.sortedByDescending { it.quote != null } + } + sorted.forEachIndexed { index, entry -> + if (index > 0) MoonItemDivider() + val isAvailable = entry.quote != null + val isChecked = isAvailable && entry.info.id == localSelectedId + + TextCheckCell( + title = entry.info.title, + subtitle = entry.rate?.rateFormatted, + subtitleColor = if (isAvailable) UIKit.colorScheme.text.secondary else UIKit.colorScheme.text.tertiary, + description = if (!isAvailable) { + when { + entry.info.minAmount.isPositive -> { + { + MoonItemSubtitle( + text = stringResource(Localization.deposit_min_amount_with_currency, entry.info.minAmount.value, currencyCode), + color = UIKit.colorScheme.accent.orange, + ) + } + } + entry.info.maxAmount != null -> { + { + MoonItemSubtitle( + text = stringResource(Localization.deposit_max_amount_with_currency, entry.info.maxAmount.value, currencyCode), + color = UIKit.colorScheme.accent.orange, + ) + } + } + else -> null + } + } else { + null + }, + tags = { + if (entry.info.isBest) { + MoonLabel( + stringResource(Localization.best), + colors = MoonLabelDefault.blue(), + ) + } + }, + isChecked = isChecked, + onCheckedChange = { + navigator.close() + if (isAvailable) { + onConfirm(entry.info.id, null) + } else { + val limitAmount = when { + entry.info.minAmount.isPositive -> entry.info.minAmount + entry.info.maxAmount != null -> entry.info.maxAmount + else -> null + } + onConfirm(entry.info.id, limitAmount) + } + }, + minHeight = 76.dp, + image = { + MoonItemImage( + shape = UIKit.shapes.large, + image = entry.info.imageUrl, + size = 44.dp, + ) + }, + ) + } + } + } + + MoonDescriptionCell( + text = stringResource(Localization.deposit_other_providers_hint) + ) + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrAssetFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrAssetFeature.kt new file mode 100644 index 000000000..2686732cf --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrAssetFeature.kt @@ -0,0 +1,257 @@ +package com.tonapps.deposit.screens.qr + +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.ton.extensions.toUserFriendly +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events +import com.tonapps.deposit.screens.qr.QrAssetFeature.Keys.Tokens +import com.tonapps.extensions.CacheKey +import com.tonapps.extensions.TimedCacheMemory +import com.tonapps.legacy.enteties.AssetsEntity +import com.tonapps.legacy.enteties.AssetsExtendedEntity +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.token.TokenRepository + +sealed interface QrAssetAction : MviAction { + object Init : QrAssetAction + object EnableTron : QrAssetAction + data class SelectTab(val tab: QrAssetTab) : QrAssetAction +} + +enum class QrAssetTab { + TON, TRON +} + +data class QrAssetSelectedData( + val token: TokenEntity, + val address: String, + val qrContent: String, + val wallet: WalletEntity, + val isBatteryEnabled: Boolean, + val showBlockchain: Boolean, +) + +data class QrAssetState( + val data: QrAssetSelectedData? = null, + val isTabsVisible: Boolean = false, +) : MviState + +class QrAssetViewState( + val global: MviProperty +) : MviViewState + +data class QrAssetData( + val token: TokenEntity? = null, + val withBuyButton: Boolean = false, +) + +sealed interface QrAssetEvent { + data object ShowTronUsdtEnable : QrAssetEvent +} + +class QrAssetFeature( + private val data: QrAssetData, + private val settingsRepository: SettingsRepository, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, + private val api: API, +) : MviFeature( + initState = QrAssetState(), + initAction = QrAssetAction.Init +) { + + interface Keys : CacheKey { + data object Tokens : Keys + } + + private val tokensCache = TimedCacheMemory() + + private val relay = MviRelay() + val events = relay.events + + init { + AnalyticsHelper.Default.simpleTrackEvent("receive_open") + AnalyticsHelper.Default.events.depositFlow.depositViewReceiveTokens( + from = Events.DepositFlow.DepositFlowFrom.WalletScreen + ) + } + + override fun createViewState(): QrAssetViewState { + return buildViewState { + QrAssetViewState(mviProperty { it }) + } + } + + private fun getDefaultToken(): TokenEntity { + return data.token ?: TokenEntity.TON + } + + override suspend fun executeAction(action: QrAssetAction) { + when (action) { + is QrAssetAction.Init -> init() + is QrAssetAction.EnableTron -> enableTron() + is QrAssetAction.SelectTab -> { + when (action.tab) { + QrAssetTab.TON -> selectToken(token = TokenEntity.TON) + QrAssetTab.TRON -> selectToken(token = TokenEntity.TRON_USDT) + } + } + } + } + + private suspend fun init() { + val wallet = accountRepository.requiredSelectedWallet() + val token = getDefaultToken() + val address = when (token.blockchain) { + Blockchain.TON -> wallet.address + Blockchain.TRON -> accountRepository.getTronAddress(wallet.id)!! + } + val qrContent = getQrContent(address, token, wallet) + val isBatteryEnabled = !api.getConfig(wallet.network).flags.disableBattery + + setState { + copy( + data = QrAssetSelectedData( + token = token, + address = address, + qrContent = qrContent, + wallet = wallet, + isBatteryEnabled = isBatteryEnabled, + showBlockchain = settingsRepository.getTronUsdtEnabled(wallet.id), + ), + ) + } + + val tokens = getTokens(wallet) + val hasTronBalance = tokens.any { it.isTrc20Usdt && it.balance.value.isPositive } + val hasToken = data.token != null + val isTronEnabled = !api.getConfig(wallet.network).flags.disableTron + val isTabsVisible = + !hasToken && wallet.hasPrivateKey && !wallet.testnet && (isTronEnabled || hasTronBalance) + + setState { copy(isTabsVisible = isTabsVisible) } + } + + private suspend fun selectToken(token: TokenEntity) { + val wallet = accountRepository.requiredSelectedWallet() + if (token == TokenEntity.TRON_USDT && !settingsRepository.getTronUsdtEnabled(wallet.id)) { + relay.emit(QrAssetEvent.ShowTronUsdtEnable) + return + } + + val tokens = getTokens(wallet) + updateToken(tokens, token, wallet) + } + + private suspend fun getTokens(wallet: WalletEntity): List { + return tokensCache.getOrLoad(Tokens) { + val isSafeMode = settingsRepository.isSafeModeEnabled(wallet.network) + + tokenRepository.mustGet( + settingsRepository.currency, + wallet.accountId, + wallet.network, + ) + .mapNotNull { token -> + if (isSafeMode && !token.verified) { + return@mapNotNull null + } + + AssetsExtendedEntity( + raw = AssetsEntity.Token(token), + prefs = settingsRepository.getTokenPrefs( + wallet.id, + token.address, + token.blacklist + ), + accountId = wallet.accountId, + ) + } + .filter { !it.isTon } + .sortedBy { it.index } + } + } + + private suspend fun enableTron() { + val wallet = accountRepository.requiredSelectedWallet() + + val tokens = getTokens(wallet) + val usdtIndex = tokens.indexOfFirst { it.isUsdt } + + val sortAddresses = mutableListOf() + tokens.forEachIndexed { index, token -> + sortAddresses.add(token.address) + if (index == usdtIndex + 1 && token.address != TokenEntity.TRON_USDT.address) { + sortAddresses.add(TokenEntity.TRON_USDT.address) + } + } + + settingsRepository.setTokenHidden(wallet.id, TokenEntity.TRON_USDT.address, false) + settingsRepository.setTokenPinned(wallet.id, TokenEntity.TRON_USDT.address, true) + settingsRepository.setTokensSort(wallet.id, sortAddresses) + + updateToken(tokens, TokenEntity.TRON_USDT, wallet) + } + + private suspend fun updateToken( + tokens: List, + token: TokenEntity, + wallet: WalletEntity + ) { + val hasTronBalance = tokens.any { + it.isTrc20Usdt && it.balance.value.isPositive + } + + val hasToken = data.token != null + val isTronEnabled = !api.getConfig(wallet.network).flags.disableTron + val isBatteryEnabled = !api.getConfig(wallet.network).flags.disableBattery + val isTabsVisible = + !hasToken && wallet.hasPrivateKey && !wallet.testnet && (isTronEnabled || hasTronBalance) + + val activeAddress = when (token.blockchain) { + Blockchain.TON -> wallet.address + Blockchain.TRON -> accountRepository.getTronAddress(wallet.id)!! + } + + val qrContent = getQrContent(activeAddress, token, wallet) + val data = QrAssetSelectedData( + token = token, + address = activeAddress, + qrContent = qrContent, + wallet = wallet, + isBatteryEnabled = isBatteryEnabled, + showBlockchain = settingsRepository.getTronUsdtEnabled(wallet.id) + ) + + setState { copy(data = data, isTabsVisible = isTabsVisible) } + } + + private fun getQrContent(address: String, token: TokenEntity, wallet: WalletEntity): String { + if (token.isUsdtTrc20 || token.isTrx) { + return address + } + + var value = "ton://transfer/${address}" + if (!token.isTon) { + value += "?jetton=${ + token.address.toUserFriendly( + wallet = false, + testnet = wallet.type == WalletType.Testnet + ) + }" + } + + return value + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrAssetFragment.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrAssetFragment.kt new file mode 100644 index 000000000..fd47a91a1 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrAssetFragment.kt @@ -0,0 +1,60 @@ +package com.tonapps.deposit.screens.qr + +import android.os.Build +import android.os.Bundle +import android.view.View +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.core.ComposableFragment +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import uikit.base.BaseFragment + +class QrAssetFragment private constructor(): ComposableFragment(), BaseFragment.BottomSheet { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val data = getData() + setContent { + val viewModel = koinViewModel { + parametersOf(data) + } + + QrScreen( + viewModel = viewModel, + showBuyButton = data.withBuyButton, + onFinishClick = { finish() }, + onBuyClick = {}, + ) + } + } + + private fun getData(): QrAssetData { + requireArguments().apply { + val token = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelable(ARG_TOKEN, TokenEntity::class.java) + } else { + @Suppress("DEPRECATION") + getParcelable(ARG_TOKEN) + } + val withBuy = getBoolean(ARG_WITH_BUY) + return QrAssetData(token, withBuy) + } + } + + companion object Companion { + // TODO to assetId + private const val ARG_TOKEN = "token" + private const val ARG_WITH_BUY = "with_buy" + + fun newInstance( + token: TokenEntity? = null, + withBuyButton: Boolean = false + ): BaseFragment { + val screen = QrAssetFragment() + token?.let { screen.putParcelableArg(ARG_TOKEN, it) } + screen.putBooleanArg(ARG_WITH_BUY, withBuyButton) + return screen + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrEnableTronDialog.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrEnableTronDialog.kt new file mode 100644 index 000000000..b9b231665 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrEnableTronDialog.kt @@ -0,0 +1,104 @@ +package com.tonapps.deposit.screens.qr + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tonapps.lib.blockchain.R +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonTopAppBarSimple +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.cell.MoonButtonCellDefaults +import ui.components.moon.cell.MoonTextContentCell +import ui.components.moon.container.BadgeDirection +import ui.components.moon.container.MoonCutBadgedBox +import ui.components.moon.container.MoonSurface +import ui.components.moon.dialog.MoonModalDialog +import ui.components.moon.dialog.rememberDialogNavigator +import ui.painterResource +import ui.preview.ThemedPreview + +@Preview(showSystemUi = true) +@Composable +private fun QrEnableTronDialogPreview() { + ThemedPreview(true) { + QrEnableTronDialog(isBatteryEnabled = true, onEnable = {}, onClose = {}) + } +} + +@Composable +fun QrEnableTronDialog( + isBatteryEnabled: Boolean, + onEnable: () -> Unit, + onClose: () -> Unit, +) { + val navigator = rememberDialogNavigator(onClose = onClose) + MoonModalDialog(navigator = navigator) { + MoonTopAppBarSimple( + title = stringResource(Localization.deposit), + actionIconRes = UIKitIcon.ic_close_16, + onActionClick = { navigator.close() }, + backgroundColor = Color.Transparent, + ) + MoonSurface { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MoonCutBadgedBox( + badge = { + MoonItemImage( + painterResource(UIKitIcon.ic_tron), + size = 40.dp, + ) + }, + direction = BadgeDirection.EndBottom, + ) { + MoonItemImage( + painter = painterResource(R.drawable.ic_usdt_with_bg), + size = 96.dp + ) + } + + Spacer(Modifier.height(16.dp)) + + MoonTextContentCell( + title = "USD₮ TRC20", + description = if (isBatteryEnabled) { + stringResource(Localization.tron_toggle_text) + } else { + stringResource(Localization.tron_toggle_trc_text) + }, + ) + + Spacer(Modifier.height(32.dp)) + + MoonButtonCell( + stringResource(Localization.enable_usdt_tron), + contentPadding = remember { PaddingValues(horizontal = 16.dp) }, + ) { + onEnable() + navigator.close() + } + + MoonButtonCell( + text = stringResource(Localization.later), + colors = MoonButtonCellDefaults.ButtonColorsSecondary, + ) { + navigator.close() + } + } + } + } +} \ No newline at end of file diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrScreen.kt new file mode 100644 index 000000000..48db15c3c --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/qr/QrScreen.kt @@ -0,0 +1,426 @@ +package com.tonapps.deposit.screens.qr + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.contract.TokenType +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.core.helper.rememberClipboardManager +import com.tonapps.core.helper.rememberShareManager +import com.tonapps.extensions.uri +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.qr.ui.QRView +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.ButtonColorsSecondary +import ui.components.moon.ButtonSizeLarge +import ui.components.moon.MoonAccentButton +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonTopAppBar +import ui.components.moon.cell.MoonTextContentCell +import ui.components.moon.container.BadgeDirection +import ui.components.moon.container.MoonCutBadgedBox +import ui.theme.Dimens +import ui.theme.Shapes +import ui.theme.UIKit +import ui.utils.uppercased + +@Composable +fun QrScreen( + viewModel: QrAssetFeature, + showBuyButton: Boolean, + onFinishClick: () -> Unit, + onBuyClick: () -> Unit, +) { + val shareManager = rememberShareManager() + val clipboardManager = rememberClipboardManager() + val lifecycle = LocalLifecycleOwner.current.lifecycle + + val global by viewModel.state.global.observeSafeState() + val token = global.data?.token + val data = global.data + + var isTronUsdtShown by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.events.flowWithLifecycle(lifecycle) + .collect { event -> + when (event) { + QrAssetEvent.ShowTronUsdtEnable -> isTronUsdtShown = true + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + MoonTopAppBar( // TODO + title = "", + navigationIconRes = UIKitIcon.ic_chevron_left_16, + onNavigationClick = { onFinishClick() }, + ignoreSystemOffset = true, + showDivider = false, + backgroundColor = Color.Transparent + ) { + if (global.isTabsVisible && token != null) { + Tabs( + token = token, + onTabClick = { viewModel.sendAction(QrAssetAction.SelectTab(it)) }, + ) + } + } + + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = Dimens.offsetLarge) + .padding(bottom = if (showBuyButton) 88.dp else 0.dp) + .width(IntrinsicSize.Max), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (data != null) { + val token = data.token + val blockchainImage = remember(token) { + when (data.token.blockchain) { + Blockchain.TON -> UIKitIcon.ic_ton + Blockchain.TRON -> UIKitIcon.ic_tron + } + } + + val name = remember(token) { + if (token.isUsdtTrc20) { + token.symbol.plus(" ${TokenType.Defined.TRC20.fmt}") + } else if (data.showBlockchain && token.isUsdt) { + token.symbol.plus(" ${TokenType.Defined.JETTON.fmt}") + } else { + "${token.name} (${token.symbol})" + } + } + + MoonTextContentCell( + title = stringResource(id = Localization.your_address), + description = if (token.isUsdtTrc20 || token.isTrx) { + stringResource(id = Localization.receive_tron_description, name) + } else { + stringResource(id = Localization.receive_coin_description, name) + } + ) + + Spacer(modifier = Modifier.height(Dimens.offsetLarge)) + + QrContent( + walletType = data.wallet.type, + walletAddress = data.address, + content = data.qrContent, + tokenImage = if (token.isTon) UIKitIcon.ic_ton.uri() else token.imageUri, + blockchainImage = if (data.showBlockchain && (token.isUsdt || token.isUsdtTrc20)) blockchainImage else null, + onCopyClick = { clipboardManager.copy(data.address) }, + onShareClick = { shareManager.share(data.address) } + ) + } + } + + if (showBuyButton) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(Dimens.offsetMedium) + .windowInsetsPadding(WindowInsets.navigationBars), + ) { + MoonAccentButton( + modifier = Modifier.fillMaxWidth(), + onClick = onBuyClick, + text = stringResource(id = Localization.buy_ton, token?.symbol ?: ""), + size = ButtonSizeLarge, + buttonColors = ButtonColorsSecondary + ) + } + } + } + + if (isTronUsdtShown && data != null) { + QrEnableTronDialog( + isBatteryEnabled = data.isBatteryEnabled, + onClose = { isTronUsdtShown = false }, + onEnable = { viewModel.sendAction(QrAssetAction.EnableTron) } + ) + } +} + +@Composable +fun QrContent( + walletType: WalletType, + walletAddress: String, + content: String?, + tokenImage: Uri, + blockchainImage: Int?, + onShareClick: () -> Unit, + onCopyClick: () -> Unit, +) { + val accentOrangeColor = UIKit.colorScheme.accent.orange + val backgroundContentTintColor = UIKit.colorScheme.background.contentTint + + val walletSpecificColor = remember(walletType, accentOrangeColor, backgroundContentTintColor) { + when (walletType) { + WalletType.Watch -> accentOrangeColor + else -> backgroundContentTintColor + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 380.dp) + .padding(horizontal = 16.dp) + .background( + color = Color.White, + shape = RoundedCornerShape(Dimens.cornerLarge) + ) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (content != null) { + QrCode( + content = content, + tokenImage = tokenImage, + blockchainImage = blockchainImage, + ) + } + + Spacer(modifier = Modifier.height(Dimens.offsetMedium)) + + Text( + text = walletAddress, + style = UIKit.typography.mono, + color = Color.Black, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .clickable { onCopyClick() } + .semantics(mergeDescendants = false) { + testTagsAsResourceId = true + testTag = "wallet_address_text" + } + ) + + if (walletType == WalletType.Watch) { + Spacer(modifier = Modifier.height(Dimens.offsetMedium)) + + Text( + text = stringResource(id = Localization.watch_only).uppercased(), + style = UIKit.typography.body4CAPS, + color = Color.Black, + textAlign = TextAlign.Center, + modifier = Modifier + .height(20.dp) + .background( + color = walletSpecificColor, + shape = RoundedCornerShape(Dimens.cornerExtraSmall) + ) + .padding(Dimens.offsetExtraSmall) + ) + } + } + + Spacer(modifier = Modifier.height(Dimens.offsetMedium)) + + QrActions( + onShareClick = onShareClick, + onCopyClick = onCopyClick + ) +} + +@Composable +private fun QrActions( + onShareClick: () -> Unit, + onCopyClick: () -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { onCopyClick() }, + modifier = Modifier.height(48.dp), + shape = RoundedCornerShape(24.dp), + colors = ButtonDefaults.buttonColors( + containerColor = UIKit.colorScheme.buttonSecondary.primaryBackground, + contentColor = UIKit.colorScheme.buttonSecondary.primaryForeground, + disabledContainerColor = UIKit.colorScheme.buttonSecondary.primaryBackgroundDisable, + disabledContentColor = UIKit.colorScheme.buttonSecondary.primaryForeground.copy( + alpha = 0.48f + ) + ), + elevation = ButtonDefaults.buttonElevation(0.dp, 0.dp, 0.dp, 0.dp, 0.dp), + contentPadding = PaddingValues(horizontal = 20.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = UIKitIcon.ic_copy_16), + contentDescription = null, + tint = UIKit.colorScheme.icon.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = Localization.copy), + style = UIKit.typography.label1 + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + IconButton( + onClick = { onShareClick() }, + modifier = Modifier + .size(48.dp) + .background(UIKit.colorScheme.buttonSecondary.primaryBackground, CircleShape) + ) { + Icon( + painter = painterResource(id = UIKitIcon.ic_share_16), + contentDescription = stringResource(id = Localization.share), + tint = UIKit.colorScheme.icon.primary + ) + } + } +} + +@Composable +fun Tabs( + modifier: Modifier = Modifier, + token: TokenEntity, + onTabClick: (tab: QrAssetTab) -> Unit +) { + val tabs = listOf(QrAssetTab.TON, QrAssetTab.TRON) + + val selectedTab = remember(token) { + if (token.isTrx || token.isUsdtTrc20) QrAssetTab.TRON else QrAssetTab.TON + } + + Row( + modifier = modifier + .wrapContentWidth() + .height(40.dp) + .background( + shape = Shapes.large, + color = UIKit.colorScheme.background.content + ) + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + tabs.forEach { tab -> + val isSelected = tab == selectedTab + Text( + text = when (tab) { + QrAssetTab.TON -> stringResource(id = Localization.ton) + QrAssetTab.TRON -> stringResource(id = Localization.trc20) + }, + modifier = Modifier + .defaultMinSize(minHeight = 32.dp) + .clip(Shapes.medium) + .background( + if (isSelected) { + UIKit.colorScheme.buttonPrimary.primaryBackground + } else { + Color.Transparent + } + ) + .wrapContentHeight(align = Alignment.CenterVertically) + .padding(horizontal = Dimens.offsetMedium) + .clickable { onTabClick(tab) }, + style = UIKit.typography.label2, + color = UIKit.colorScheme.text.primary + ) + } + } +} + +@Composable +private fun QrCode( + content: String, + tokenImage: Uri, + blockchainImage: Int?, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + contentAlignment = Alignment.Center + ) { + AndroidView( + modifier = Modifier.fillMaxWidth(), + factory = { context -> + QRView(context).apply { + withCutout = true + } + }, + update = { view -> + view.withCutout = true + view.setContent(content) + } + ) + + Box( + modifier = Modifier.size(46.dp), + ) { + if (blockchainImage != null) { + MoonCutBadgedBox( + direction = BadgeDirection.EndBottom, + badge = { MoonItemImage(size = 18.dp, image = blockchainImage) }, + content = { MoonItemImage(size = 46.dp, image = tokenImage.toString()) } + ) + } else { + MoonItemImage( + image = tokenImage.toString(), + size = 46.dp, + ) + } + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampFeature.kt new file mode 100644 index 000000000..505b9eedd --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampFeature.kt @@ -0,0 +1,133 @@ +package com.tonapps.deposit.screens.ramp + +import com.tonapps.async.Async +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events.DepositFlow.DepositFlowSellAsset +import com.tonapps.bus.generated.Events.WithdrawFlow.WithdrawFlowBuyAsset +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.deposit.data.toWalletCurrency +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.toBuyAsset +import com.tonapps.deposit.toSellAsset +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import io.exchangeapi.models.CreateP2PSessionRequest +import io.exchangeapi.models.ExchangeLayoutItem +import io.exchangeapi.models.ExchangeLayoutItemType +import io.exchangeapi.models.ExchangeMerchantInfo +import kotlinx.coroutines.launch + +sealed interface RampAction : MviAction { + data object Init : RampAction +} + +sealed interface RampState : MviState { + object Loading : RampState + + object Empty : RampState + + data class Data( + val fiatItem: ExchangeLayoutItem? = null, + val cryptoItem: ExchangeLayoutItem? = null, + val cryptoAsset: RampAsset? = null, + val stablecoinItem: ExchangeLayoutItem? = null, + val isExchangeAvailable: Boolean = true, + val isSendAvailable: Boolean = true, + ) : RampState +} + +class RampViewState( + val global: MviProperty +) : MviViewState + +class RampFeature( + private val rampType: RampType, + private val onRampRepository: ExchangeRepository, + private val accountRepository: AccountRepository, +) : MviFeature( + initState = RampState.Loading, + initAction = RampAction.Init +) { + + override fun createViewState(): RampViewState { + return buildViewState { + RampViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: RampAction) { + when (action) { + is RampAction.Init -> loadAssets() + } + } + + private suspend fun loadAssets() { + try { + setState { RampState.Loading } + updateAssets() + } catch (e: Throwable) { + L.e(e) + setState { RampState.Empty } + } + } + + private suspend fun updateAssets() { + val wallet = accountRepository.getSelectedWallet() + ?: return + + val network = wallet.network + if (!network.isMainnet) { + setState { + RampState.Data( + isExchangeAvailable = false, + isSendAvailable = !wallet.isWatchOnly, + ) + } + return + } + + if (wallet.isWatchOnly) { + setState { + RampState.Data( + isExchangeAvailable = false, + isSendAvailable = false, + ) + } + return + } + + val layout = onRampRepository.getLayout(rampType) + + val fiatItem = layout.items.firstOrNull { it.type == ExchangeLayoutItemType.fiat } + val cryptoItem = layout.items.firstOrNull { it.type == ExchangeLayoutItemType.crypto } + val stablecoinItem = layout.items.firstOrNull { it.type == ExchangeLayoutItemType.stablecoin } + + // Crypto always has a single asset — pre-extract it for direct navigation + val cryptoAsset = cryptoItem?.assets?.firstOrNull()?.let { + RampAsset.Currency(it.toWalletCurrency()) + } + + setState { + RampState.Data( + fiatItem = fiatItem, + cryptoItem = cryptoItem, + cryptoAsset = cryptoAsset, + stablecoinItem = stablecoinItem, + ) + } + } + + override fun onCleared() { + super.onCleared() + Async.globalScope().launch { + onRampRepository.clearRampCache(rampType) + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampScreen.kt new file mode 100644 index 000000000..8a666f2a5 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampScreen.kt @@ -0,0 +1,186 @@ +package com.tonapps.deposit.screens.ramp + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.screens.provider.ProviderConfirmDialog +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import io.exchangeapi.models.ExchangeLayoutItem +import io.exchangeapi.models.ExchangeMerchantInfo +import ui.components.moon.MoonCircleIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.cell.MoonCardCell +import ui.components.moon.cell.MoonLoaderCell +import ui.components.moon.container.MoonScaffold +import ui.painterResource +import ui.theme.UIKit + +@Composable +fun RampScreen( + feature: RampFeature, + rampType: RampType, + onClose: () -> Unit, + onSend: () -> Unit, + onQr: () -> Unit, + onBuyCash: (preferredCurrency: String?) -> Unit, + onBuyCrypto: (RampAsset) -> Unit, + onBuyStablecoins: () -> Unit, +) { + val state by feature.state.global.observeSafeState() + + RampScreenContent( + state = state, + rampType = rampType, + onClose = onClose, + onSend = onSend, + onQr = onQr, + onBuyCash = onBuyCash, + onBuyCrypto = onBuyCrypto, + onBuyStablecoins = onBuyStablecoins, + ) +} + +@Composable +private fun RampScreenContent( + state: RampState, + rampType: RampType, + onClose: () -> Unit, + onSend: () -> Unit, + onQr: () -> Unit, + onBuyCash: (preferredCurrency: String?) -> Unit, + onBuyCrypto: (RampAsset) -> Unit, + onBuyStablecoins: () -> Unit, +) { + val isSendAvailable = (state as? RampState.Data)?.isSendAvailable ?: true + + MoonScaffold( + Modifier + .verticalScroll(rememberScrollState()) + .nestedScroll(rememberNestedScrollInteropConnection()), + title = stringResource( + when (rampType) { + RampType.RampOn -> Localization.add_funds + RampType.RampOff -> Localization.withdraw + } + ), + onClose = onClose + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + when (rampType) { + RampType.RampOn -> TransferActionCell( + icon = painterResource(id = UIKitIcon.ic_qr_code_28), + title = stringResource(Localization.receive_tokens), + description = stringResource(Localization.deposit_from_another_wallet), + onClick = onQr, + ) + RampType.RampOff -> if (isSendAvailable) { + TransferActionCell( + icon = painterResource(id = UIKitIcon.ic_tray_arrow_up_28), + title = stringResource(Localization.send_tokens), + description = stringResource(Localization.deposit_to_another_wallet), + onClick = onSend, + ) + } + } + + when (state) { + is RampState.Loading -> MoonLoaderCell() + is RampState.Empty -> MoonLoaderCell() + is RampState.Data -> { + state.fiatItem?.let { item -> + LayoutItemCell( + item = item, + onClick = { onBuyCash(item.preferredCurrency) }, + ) + } + state.cryptoItem?.let { item -> + val asset = state.cryptoAsset ?: return@let + LayoutItemCell(item = item, onClick = { onBuyCrypto(asset) }) + } + state.stablecoinItem?.let { item -> + LayoutItemCell(item = item, onClick = onBuyStablecoins) + } + } + } + } + } +} + +@Composable +private fun TransferActionCell( + icon: Painter, + title: String, + description: String, + onClick: () -> Unit +) { + val color = UIKit.colorScheme.accent.blue + val bg = remember(color) { + color.copy(alpha = 0.13f) + } + + ActionCell( + image = { + MoonCircleIcon( + modifier = Modifier + .clip(CircleShape) + .background(bg), + size = 44.dp, + iconSize = 28.dp, + painter = icon, + color = Color.Transparent, + ) + }, + title = title, + description = description, + onClick = onClick, + ) +} + +@Composable +private fun LayoutItemCell(item: ExchangeLayoutItem, onClick: () -> Unit) { + ActionCell( + image = { MoonItemImage(image = item.image, size = 44.dp) }, + title = item.title, + description = item.description, + onClick = onClick, + ) +} + +@Composable +private fun ActionCell( + image: @Composable () -> Unit, + title: String, + description: String, + onClick: () -> Unit +) { + MoonCardCell( + image = image, + title = title, + subtitle = description, + onClick = onClick, + maxLinesTitle = 2, + maxLinesSubtitle = 2, + ) +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampType.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampType.kt new file mode 100644 index 000000000..8316afeb4 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/RampType.kt @@ -0,0 +1,6 @@ +package com.tonapps.deposit.screens.ramp + +enum class RampType { + RampOn, + RampOff, +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountData.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountData.kt new file mode 100644 index 000000000..fb2b199d1 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountData.kt @@ -0,0 +1,31 @@ +package com.tonapps.deposit.screens.ramp.amount + +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.screens.ramp.RampType +import io.exchangeapi.models.ExchangeDirection + +data class RampAmountData( + val assetFrom: RampAsset, + val assetTo: RampAsset, + val paymentMethodType: String, +) { + val fromNetwork: String? = assetFrom.network + val toNetwork: String? = assetTo.network + + val purchaseType: ExchangeDirection = when { + fromNetwork == null -> ExchangeDirection.buy + toNetwork == null -> ExchangeDirection.sell + else -> ExchangeDirection.swap + } + + val rampType: RampType = when { + fromNetwork == null -> RampType.RampOn + toNetwork == null -> RampType.RampOff + else -> throw IllegalStateException("Can't be so!") + } + + val cryptoAsset: RampAsset = if (fromNetwork != null) assetFrom else assetTo + val fiatAsset: RampAsset = if (fromNetwork != null) assetTo else assetFrom + val cryptoCode: String = cryptoAsset.currencyCode + val fiatCode: String = fiatAsset.currencyCode +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountFeature.kt new file mode 100644 index 000000000..4df457578 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountFeature.kt @@ -0,0 +1,494 @@ +package com.tonapps.deposit.screens.ramp.amount + +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events.WithdrawFlow.WithdrawFlowBuyAsset +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.deposit.data.assetsOfType +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.screens.provider.ProviderItem +import com.tonapps.deposit.screens.provider.ProviderQuote +import com.tonapps.deposit.screens.provider.ProviderRate +import com.tonapps.deposit.screens.provider.ProviderWithQuote +import com.tonapps.deposit.toBuyAsset +import com.tonapps.deposit.toSellAsset +import com.tonapps.icu.Coins +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.MviSubject +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.token.TokenRepository +import io.exchangeapi.models.ExchangeCalculateRequest +import io.exchangeapi.models.ExchangeCalculation +import io.exchangeapi.models.ExchangeDirection +import io.exchangeapi.models.ExchangeFlow +import io.exchangeapi.models.ExchangeLayoutItemType +import io.exchangeapi.models.ExchangePaymentMethodType +import io.exchangeapi.models.ExchangeQuote +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import java.math.BigDecimal + +private val ExchangeDirection?.isBuy get() = this == ExchangeDirection.buy +private val ExchangeDirection?.isSell get() = this == ExchangeDirection.sell + +sealed interface DepositAmountAction : MviAction { + object Init : DepositAmountAction + // object ToggleInputCurrency : DepositAmountAction + class SelectProvider(val providerId: String) : DepositAmountAction + class CheckProvider(val provider: ProviderWithQuote) : DepositAmountAction + class AllowProvider(val provider: ProviderWithQuote, val isDontShowAgain: Boolean) : DepositAmountAction +} + +sealed interface DepositAmountState : MviState { + data object Loading : DepositAmountState + + data class Data( + val asset: RampAsset, + val paymentMethodType: String, + val currencyCode: String, + val fiatCode: String, + val balance: BalanceEntity? = null, + val purchaseType: ExchangeDirection? = null, + val providers: List = emptyList(), + val defaultAmount: String, + val initialAmount: String? = null, + val selectedProviderId: String? = null, + val isCalculating: Boolean = false, + val selectedErrorProvider: ProviderItem? = null, + ) : DepositAmountState { + val selectedProvider: ProviderWithQuote? + get() = providers.find { it.info.id == selectedProviderId } + + val canContinue: Boolean + get() = !isCalculating && selectedProvider?.quote?.widgetUrl != null + + // buy = input fiat, sell = input crypto + val inputCurrencyCode: String + get() = if (purchaseType.isBuy) fiatCode else currencyCode + + val receiveCurrencyCode: String + get() = if (purchaseType.isBuy) currencyCode else fiatCode + } +} + +sealed interface DepositAmountEvent { + data object ShowConfirmation : DepositAmountEvent + class Continue(val providerUrl: String) : DepositAmountEvent +} + +class DepositAmountViewState( + val global: MviProperty +) : MviViewState + +@OptIn(FlowPreview::class) +class DepositAmountFeature( + val data: RampAmountData, + private val onRampRepository: ExchangeRepository, + private val settingsRepository: SettingsRepository, + private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, +) : MviFeature( + initState = DepositAmountState.Loading, + initAction = DepositAmountAction.Init +) { + + private val relay = MviRelay() + val events = relay.events + + private val amountSubject = MviSubject() + + init { + amountSubject.events + .debounce(300) + .distinctUntilChanged() + .mapLatest { amount -> onAmountChanged(amount) } + .launchIn(stateScope) + } + + override fun createViewState(): DepositAmountViewState { + return buildViewState { + DepositAmountViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: DepositAmountAction) { + when (action) { + is DepositAmountAction.Init -> loadData() + // is DepositAmountAction.ToggleInputCurrency -> toggleInputCurrency() + is DepositAmountAction.SelectProvider -> selectProvider(action.providerId) + is DepositAmountAction.CheckProvider -> { + val wallet = accountRepository.getSelectedWallet() ?: return + val shouldValidated = settingsRepository.isPurchaseOpenConfirm(wallet.id, action.provider.info.id) + if (shouldValidated) { + relay.emit(DepositAmountEvent.ShowConfirmation) + } else { + onContinue(action.provider) + } + } + + is DepositAmountAction.AllowProvider -> { + val wallet = accountRepository.getSelectedWallet() ?: return + if (action.isDontShowAgain) { + settingsRepository.disablePurchaseOpenConfirm(wallet.id, action.provider.info.id) + } + + onContinue(action.provider) + } + } + } + + private fun onContinue(provider: ProviderWithQuote) { + runCatching { + provider.quote?.let { quote -> + val sellAmount = quote.amount.value.toDouble() + + if (data.purchaseType.isSell) { + AnalyticsHelper.Default.events.withdrawFlow.withdrawClickOnrampContinue( + sellAsset = data.assetFrom.toCurrency.toSellAsset(), + providerName = provider.info.title, + sellAmount = sellAmount + ) + } else { + AnalyticsHelper.Default.events.depositFlow.depositClickOnrampContinue( + buyAsset = data.assetTo.toCurrency.toBuyAsset(), + providerName = provider.info.title, + buyAmount = quote.receiveAmount?.toDoubleOrNull() ?: 0.0 + ) + } + } + } + + val quote = provider.quote ?: return + val url = quote.widgetUrl + val txId = quote.merchantTransactionId ?: "" + + runCatching { + if (data.purchaseType.isSell) { + AnalyticsHelper.Default.events.withdrawFlow.withdrawViewOnrampFlow( + sellAsset = data.assetFrom.toCurrency.toSellAsset(), + providerName = provider.info.title, + sellAmount = quote.amount.value.toDouble(), + buyAsset = WithdrawFlowBuyAsset.Fiat, + txId = txId + ) + } else { + AnalyticsHelper.Default.events.depositFlow.depositViewOnrampFlow( + buyAsset = data.assetTo.toCurrency.toBuyAsset(), + providerName = provider.info.title, + buyAmount = quote.receiveAmount?.toDoubleOrNull() ?: 0.0, + txId = txId + ) + } + } + + relay.emit(DepositAmountEvent.Continue(url)) + } + + fun onAmountInput(amount: String) { + amountSubject.emit(amount) + } + + private suspend fun loadData() { + val cryptoCode = data.cryptoCode + val fiatCode = data.fiatCode + try { + val wallet = accountRepository.forceSelectedWallet() + + val minValue = try { + onRampRepository.getLayoutCurrency(data.rampType, data.fiatAsset.toCurrency) + .assetsOfType(ExchangeLayoutItemType.fiat) + .firstOrNull { + it.networkName == data.cryptoAsset.toCurrency.title + && it.symbol == data.cryptoAsset.toCurrency.symbol + } + ?.cashMethods + ?.firstOrNull { it.type.value == data.paymentMethodType } + ?.providers + ?.mapNotNull { it.limits?.min?.toBigDecimal() } + ?.filter { it > BigDecimal.ZERO } + ?.minOrNull() + } catch (e: Throwable) { + L.e(e) + null + } + + val fromCurrency = data.assetFrom.toCurrency + val balance = if (data.purchaseType.isSell) { + val tronAddress = if (fromCurrency.isTronChain) accountRepository.getTronAddress(wallet.id) else null + tokenRepository.get(fromCurrency, wallet.accountId, wallet.network, tronAddress = tronAddress) + ?.firstOrNull { it.address.equals(fromCurrency.address, ignoreCase = true) && it.balance.isTransferable } + ?.balance + } else { + null + } + + val merchants = onRampRepository.getMerchants() + + val providerItems = merchants.map { merchant -> + ProviderWithQuote( + info = ProviderItem( + id = merchant.id, + title = merchant.title, + imageUrl = merchant.image, + isBest = false, + buttons = merchant.buttons, + ) + ) + } + + val initialAmountStr = if (data.purchaseType.isSell) null else minValue?.toString() + + setState { + DepositAmountState.Data( + asset = data.assetTo, + paymentMethodType = data.paymentMethodType, + currencyCode = cryptoCode, + fiatCode = fiatCode, + purchaseType = data.purchaseType, + providers = providerItems, + defaultAmount = defaultAmount(if (data.purchaseType.isBuy) cryptoCode else fiatCode), + initialAmount = initialAmountStr, + balance = balance, + ) + } + + if (initialAmountStr != null) { + amountSubject.emit(initialAmountStr) + } + } catch (e: Throwable) { + L.e(e) + setState { + DepositAmountState.Data( + asset = data.assetTo, + paymentMethodType = data.paymentMethodType, + currencyCode = cryptoCode, + fiatCode = fiatCode, + purchaseType = data.purchaseType, + providers = emptyList(), + defaultAmount = defaultAmount(if (data.purchaseType.isBuy) cryptoCode else fiatCode), + ) + } + } + } + + private suspend fun onAmountChanged(amountText: String) { + val amount = try { + val coins = Coins.of(amountText) + if (coins.isPositive) coins else null + } catch (_: Throwable) { + null + } + + val wallet = accountRepository.getSelectedWallet() + ?: return + + val address = if (data.purchaseType.isSell) { + when (data.assetFrom.toCurrency.isTronChain) { + true -> accountRepository.getTronAddress(wallet.id) ?: return + false -> wallet.address + } + } else { + when (data.assetTo.toCurrency.isTronChain) { + true -> accountRepository.getTronAddress(wallet.id) ?: return + false -> wallet.address + } + } + + if (amount == null) { + setState { + copy( + isCalculating = false, + providers = providers.map { it.copy(rate = null, quote = null) } + ) + } + return + } + + if (data.purchaseType.isSell) { + AnalyticsHelper.Default.events.withdrawFlow.withdrawViewOnrampInsertAmount( + sellAsset = data.assetFrom.toCurrency.toSellAsset(), + providerName = data.paymentMethodType + ) + } else { + AnalyticsHelper.Default.events.depositFlow.depositViewOnrampInsertAmount( + buyAsset = data.assetTo.toCurrency.toBuyAsset(), + providerName = data.paymentMethodType + ) + } + + setState { + copy(isCalculating = true, selectedErrorProvider = null) + } + + val stateData = obtainSpecificState() ?: return + + try { + val paymentMethodType = ExchangePaymentMethodType.decode(stateData.paymentMethodType) + + val fromCode = data.assetFrom.currencyCode + val toCode = data.assetTo.currencyCode + val result = onRampRepository.calculate( + ExchangeCalculateRequest( + from = fromCode, + to = toCode, + amount = Coins.string(amount), + wallet = address, + purchaseType = data.purchaseType, + fromNetwork = data.fromNetwork, + toNetwork = data.toNetwork, + paymentMethod = paymentMethodType, + flow = if (data.purchaseType.isSell) ExchangeFlow.withdraw else ExchangeFlow.deposit + ) + ) + + val quotesMap = buildQuotesMap(result) + val allQuotesMap = (result.items + result.suggested).associateBy { it.merchant.value } + val bestProviderId = result.items.firstOrNull()?.merchant?.value + val calculateProviderIds = allQuotesMap.keys + + setState { + var selectedProvider: ProviderWithQuote? = null + + val updatedProviders = providers + .filter { it.info.id in calculateProviderIds } + .map { entry -> + val onRampQuote = quotesMap[entry.info.id] + val exchangeQuote = allQuotesMap[entry.info.id] + val widgetUrl = onRampQuote?.widgetUrl + + val isAvailable = onRampQuote != null && widgetUrl != null + + val providerRate = exchangeQuote?.let { + val receiveCoins = Coins.of(it.amount) + val rateBase = if (isAvailable) { + amount + } else { + val min = it.minAmount?.let { v -> Coins.of(v) } + val max = it.maxAmount?.let { v -> Coins.of(v) } + when { + min != null && amount < min -> min + max != null && amount > max -> max + else -> min ?: max ?: amount + } + } + val ratePerUnit = if (rateBase.isPositive) receiveCoins / rateBase else Coins.ZERO + ProviderRate( + rateFormatted = formatRate(ratePerUnit), + ) + } + + if (isAvailable) { + val receiveCoins = Coins.of(onRampQuote.amount) + entry.copy( + info = entry.info.copy(isBest = entry.info.id == bestProviderId), + rate = providerRate, + quote = ProviderQuote( + amount = amount, + receiveCoins = receiveCoins, + currencyCode = currencyCode, + widgetUrl = widgetUrl, + receiveAmount = defaultAmount( + receiveCurrencyCode, + receiveCoins + ), + merchantTransactionId = onRampQuote.merchantTransactionId, + ), + ) + } else { + val minAmount = exchangeQuote?.minAmount?.let { Coins.of(it) } ?: Coins.ZERO + val maxAmount = exchangeQuote?.maxAmount?.let { Coins.of(it) } + entry.copy( + info = entry.info.copy( + isBest = false, + minAmount = minAmount, + maxAmount = maxAmount, + ), + rate = providerRate, + quote = null, + ) + } + } + + if (selectedProvider == null) { + selectedProvider = updatedProviders.firstOrNull { it.info.isBest && it.quote != null } + } + + val errorProvider = if (selectedProvider == null) { + val suggestedWithMin = result.suggested + .filter { it.minAmount != null } + .minByOrNull { Coins.of(it.minAmount!!) } + val suggestedWithMax = result.suggested + .filter { it.maxAmount != null } + .maxByOrNull { Coins.of(it.maxAmount!!) } + + val suggestedEntry = suggestedWithMin ?: suggestedWithMax + suggestedEntry?.let { entry -> + val info = updatedProviders.find { it.info.id == entry.merchant.value }?.info + ?: providers.firstOrNull()?.info + info?.copy( + minAmount = entry.minAmount?.let { Coins.of(it) } ?: Coins.ZERO, + maxAmount = entry.maxAmount?.let { Coins.of(it) }, + ) + } ?: providers.firstOrNull()?.info + } else { + null + } + + copy( + providers = updatedProviders, + selectedProviderId = selectedProvider?.info?.id, + isCalculating = false, + selectedErrorProvider = errorProvider, + ) + } + } catch (e: Throwable) { + L.e(e) + setState { + copy( + isCalculating = false, + providers = providers.map { it.copy(rate = null, quote = null) } + ) + } + } + } + + private fun formatRate(ratePerUnit: Coins): String { + val fromCode = + if (data.purchaseType.isBuy) data.fiatCode else data.cryptoCode + val toCode = + if (data.purchaseType.isBuy) data.cryptoCode else data.fiatCode + return "1 $fromCode ≈ ${defaultAmount(toCode, ratePerUnit)}" + } + + private fun defaultAmount(fiatCode: String, coin: Coins = Coins.ZERO): String { + return CurrencyFormatter.format(fiatCode, coin, replaceSymbol = false) + .toString() + } + + private fun buildQuotesMap(calculation: ExchangeCalculation): Map { + return calculation.items.associateBy { it.merchant.value } + } + + // private fun toggleInputCurrency() { + // setState { + // copy(isInputCrypto = !isInputCrypto) + // } + // } + + private fun selectProvider(providerId: String) { + setState { + copy(selectedProviderId = providerId) + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountScreen.kt new file mode 100644 index 000000000..d1b0ff190 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/ramp/amount/RampAmountScreen.kt @@ -0,0 +1,520 @@ +package com.tonapps.deposit.screens.ramp.amount + +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.flowWithLifecycle +import com.tonapps.deposit.screens.provider.ProviderConfirmDialog +import com.tonapps.deposit.screens.provider.ProviderItem +import com.tonapps.deposit.screens.provider.ProviderWithQuote +import com.tonapps.deposit.screens.provider.SelectProviderDialog +import com.tonapps.deposit.utils.AmountError +import com.tonapps.deposit.utils.ProviderAmountState +import com.tonapps.deposit.utils.rememberProviderAmountState +import com.tonapps.icu.Coins +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import com.wallet.crypto.trustapp.common.ui.components.MoonEditText +import ui.components.moon.MoonBadgeButton +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonItemSubtitle +import ui.components.moon.MoonLabel +import ui.components.moon.MoonLabelDefault +import ui.components.moon.MoonSmallItemTitle +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.cell.MoonLoaderCell +import ui.components.moon.cell.TextCell +import ui.components.moon.container.MoonScaffold +import ui.painterResource +import ui.theme.UIKit +import ui.theme.modifiers.rememberShimmerPhase +import ui.theme.modifiers.shimmer +import ui.theme.outlineStoke +import ui.utils.toRichSpanStyle +import ui.utils.uppercased +import java.math.RoundingMode + +@Composable +fun DepositAmountScreen( + feature: DepositAmountFeature, + onBack: () -> Unit, + onClose: () -> Unit, + onContinue: (String) -> Unit, +) { + DepositAmountContent( + feature = feature, + onBack = onBack, + onClose = onClose, + onProviderSelected = { feature.sendAction(DepositAmountAction.SelectProvider(it)) }, + onAmountInput = feature::onAmountInput, + onToggleInputCurrency = {}, // TODO + onContinue = onContinue, + ) +} + +@Composable +private fun DepositAmountContent( + feature: DepositAmountFeature, + onBack: () -> Unit, + onClose: () -> Unit, + onProviderSelected: (String) -> Unit, + onAmountInput: (String) -> Unit, + onToggleInputCurrency: () -> Unit, + onContinue: (String) -> Unit, +) { + val state by feature.state.global.observeSafeState() + val lifecycle = LocalLifecycleOwner.current + var showConfirmationDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + feature.events.flowWithLifecycle(lifecycle.lifecycle) + .collect { + when (it) { + is DepositAmountEvent.ShowConfirmation -> showConfirmationDialog = true + is DepositAmountEvent.Continue -> { + showConfirmationDialog = false + onContinue(it.providerUrl) + } + } + } + } + + MoonScaffold( + Modifier.imePadding(), + title = stringResource(Localization.deposit_insert_amount), + onClose = onClose, + onBack = onBack, + ) { + when (val state = state) { + is DepositAmountState.Loading -> MoonLoaderCell() + is DepositAmountState.Data -> { + DataContent( + state = state, + onProviderSelected = onProviderSelected, + onAmountInput = onAmountInput, + onToggleInputCurrency = onToggleInputCurrency, + onContinue = { feature.sendAction(DepositAmountAction.CheckProvider(it)) }, + ) + + val selected = state.selectedProvider + if (showConfirmationDialog && selected != null) { + val style = UIKit.typography.body1 + val color = UIKit.colorScheme.text.accent + val buttons = selected.info.buttons + val description = remember(color, buttons) { + if (buttons.isEmpty()) return@remember null + buildAnnotatedString { + val linkStyle = style.toRichSpanStyle(color = color) + buttons.forEachIndexed { index, button -> + if (index > 0) append(" · ") + withLink( + LinkAnnotation.Url( + url = button.url, + styles = TextLinkStyles(linkStyle), + ) + ) { append(button.title) } + } + } + } + + ProviderConfirmDialog( + title = selected.info.title, + icon = selected.info.imageUrl, + description = description, + onClose = { showConfirmationDialog = false }, + onConfirm = { + feature.sendAction( + DepositAmountAction.AllowProvider( + selected, + it + ) + ) + } + ) + } + } + } + } +} + +@Composable +private fun DataContent( + state: DepositAmountState.Data, + onProviderSelected: (String) -> Unit, + onAmountInput: (String) -> Unit, + onToggleInputCurrency: () -> Unit, + onContinue: (ProviderWithQuote) -> Unit, +) { + val amountState = rememberProviderAmountState( + provider = state.selectedProvider?.info ?: state.selectedErrorProvider, + ) + + LaunchedEffect(state.initialAmount) { + val initial = state.initialAmount + if (initial != null && amountState.text.isEmpty()) { + amountState.onTextChange(initial) + } + } + + val selected = state.selectedProvider ?: state.providers.firstOrNull() + var showProviderDialog by remember { mutableStateOf(false) } + + Column( + Modifier.fillMaxSize() + ) { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .nestedScroll(rememberNestedScrollInteropConnection()) + ) { + AmountInputCell( + amountState = amountState, + currencyCode = state.inputCurrencyCode, + receiveAmount = selected?.quote?.receiveAmount ?: state.defaultAmount, + onAmountInput = onAmountInput, + onToggleInputCurrency = { + val newAmount = state.selectedProvider?.quote?.receiveCoins + ?.let { Coins.string(it) } + onToggleInputCurrency() + if (newAmount != null) { + amountState.onTextChange(newAmount) + onAmountInput(newAmount) + } + }, + isLoading = state.isCalculating, + onDone = { + if (amountState.isValid) { + state.selectedProvider?.let { onContinue(it) } + } + } + ) + + state.balance?.let { balance -> + val insufficientBalance = amountState.coins.isPositive && amountState.coins > balance.uiBalance + val availableFormatted = remember(balance) { + CurrencyFormatter.format( + currency = balance.token.symbol, + value = balance.uiBalance, + roundingMode = RoundingMode.DOWN, + replaceSymbol = false, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + MoonBadgeButton( + content = { + MoonSmallItemTitle(stringResource(Localization.max).uppercased()) + }, + onClick = { + val maxAmount = Coins.string(balance.uiBalance) + amountState.onTextChange(maxAmount) + onAmountInput(maxAmount) + }, + ) + + if (insufficientBalance) { + MoonItemSubtitle( + text = stringResource(Localization.insufficient_balance), + color = UIKit.colorScheme.accent.red, + ) + } else { + MoonItemSubtitle( + text = stringResource(Localization.available, availableFormatted), + color = UIKit.colorScheme.text.secondary, + ) + } + } + } + + if (state.isCalculating) { + val shimmer by rememberShimmerPhase() + ProviderCell( + modifier = Modifier.shimmer(shimmer), + provider = ProviderItem.EMPTY, + rateFormatted = "", + onClick = null, + ) + } else if (selected?.quote != null) { + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + ProviderCell( + provider = selected.info, + rateFormatted = selected.rate?.rateFormatted, + onClick = { + keyboardController?.hide() + focusManager.clearFocus(true) + showProviderDialog = true + }, + ) + } else if (state.providers.isEmpty()) { + EmptyProviderCell() + } + } + + val insufficientBalance = state.balance?.let { amountState.coins.isPositive && amountState.coins > it.uiBalance } ?: false + MoonButtonCell( + text = stringResource(Localization.continue_action), + enabled = state.canContinue + && amountState.isValid + && !insufficientBalance + && amountState.coins.isSame(state.selectedProvider?.quote?.amount), + ) { + state.selectedProvider?.let { onContinue(it) } + } + } + + if (showProviderDialog) { + SelectProviderDialog( + providers = state.providers, + selectedProviderId = state.selectedProviderId, + currencyCode = state.inputCurrencyCode, + onConfirm = { providerId, minAmount -> + if (minAmount != null) { + val amountStr = Coins.string(minAmount) + amountState.onTextChange(amountStr) + onAmountInput(amountStr) + } + onProviderSelected(providerId) + }, + onClose = { showProviderDialog = false }, + ) + } +} + +@Composable +private fun AmountInputCell( + amountState: ProviderAmountState, + currencyCode: String, + receiveAmount: String, + isLoading: Boolean, + onAmountInput: (String) -> Unit, + onToggleInputCurrency: () -> Unit, + onDone: () -> Unit, +) { + MoonBundleCell { + Column( + Modifier + .padding(remember { + PaddingValues( + top = 39.dp, + bottom = 6.dp, + start = 16.dp, + end = 16.dp + ) + }) + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier.padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + ) { + val focusRequester = remember { FocusRequester() } + val scrollState = rememberScrollState() + + val style = remember { TextStyle(fontSize = 40.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp) } + + LaunchedEffect(amountState.value.text) { + scrollState.animateScrollTo(scrollState.maxValue) + } + + MoonEditText( + paddingValues = PaddingValues(), + modifier = Modifier + .focusRequester(focusRequester) + .weight(1f, fill = false) + .horizontalScroll(scrollState) + .width(IntrinsicSize.Min) + .defaultMinSize(minWidth = 24.dp), + value = amountState.value, + onValueChange = { newValue -> + amountState.onValueChange(newValue) + onAmountInput(amountState.value.text) + }, + textStyle = style, + keyboardActions = remember { + KeyboardActions(onDone = { onDone() }) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + placeholder = { Text(text = "0", style = style, color = UIKit.colorScheme.text.tertiary) }, + ) + + Text( + modifier = Modifier.padding(bottom = 4.dp), + text = currencyCode, + style = UIKit.typography.num2, + color = UIKit.colorScheme.text.secondary, + ) + + DisposableEffect(Unit) { + focusRequester.requestFocus() + + onDispose { + focusRequester.freeFocus() + } + } + } + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier + .padding(4.dp) + .clip(CircleShape) +// .clickable(onClick = onToggleInputCurrency) + .border( + border = outlineStoke(), + shape = CircleShape + ) + .run { + if (isLoading) { + val shimmer by rememberShimmerPhase() + shimmer(shimmer) + } else { + this + } + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = receiveAmount, + color = UIKit.colorScheme.text.secondary, + style = UIKit.typography.body1, + ) + +// MoonItemIcon( +// painter = painterResource(UIKitIcon.ic_swap_vertical_16), +// color = UIKit.colorScheme.icon.secondary, +// ) + } + + val error = if (isLoading) null else amountState.error + MoonItemSubtitle( + modifier = Modifier + .alpha(if (error != null) 1f else 0f) + .padding(vertical = 12.dp, horizontal = 6.dp), + text = when (error) { + is AmountError.AboveMax -> stringResource(Localization.max_amount, error.maxAmount.value) + is AmountError.BelowMin -> stringResource(Localization.min_amount, error.minAmount.value) + null -> "" + }, + color = UIKit.colorScheme.accent.red, + ) + } + } +} + +@Composable +private fun EmptyProviderCell() { + MoonBundleCell( + contentPadding = remember { PaddingValues(16.dp) } + ) { + TextCell( + title = stringResource(Localization.no_available_provider), + titleColor = UIKit.colorScheme.text.secondary, + subtitle = null, + onClick = null, + minHeight = 76.dp, + image = { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_wallet_28), + color = UIKit.colorScheme.icon.secondary, + ) + }, + ) + } +} + +@Composable +private fun ProviderCell( + provider: ProviderItem, + rateFormatted: String?, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + MoonBundleCell( + contentPadding = remember { PaddingValues(16.dp) } + ) { + TextCell( + modifier = modifier, + title = provider.title, + subtitle = rateFormatted, + tags = { + if (provider.isBest) { + MoonLabel(stringResource(Localization.best), colors = MoonLabelDefault.blue()) + } + }, + onClick = onClick, + content = { + MoonItemIcon(painterResource(UIKitIcon.ic_switch_16)) + }, + minHeight = 76.dp, + image = { + MoonItemImage( + shape = UIKit.shapes.large, + image = provider.imageUrl, + size = 44.dp, + ) + }, + ) + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/SendException.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendException.kt similarity index 86% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/SendException.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendException.kt index b223cd319..0f7f09636 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/SendException.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendException.kt @@ -1,4 +1,4 @@ -package com.tonapps.tonkeeper.ui.screen.send.main +package com.tonapps.deposit.screens.send sealed class SendException: Exception() { class FailedToSendTransaction: SendException() diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendExchangeData.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendExchangeData.kt new file mode 100644 index 000000000..5880e1f16 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendExchangeData.kt @@ -0,0 +1,13 @@ +package com.tonapps.deposit.screens.send + +import com.tonapps.blockchain.model.legacy.WalletCurrency +import kotlinx.serialization.Serializable + +@Serializable +data class SendExchangeData( + val exchangeTo: WalletCurrency, + val withdrawalFee: String? = null, + val fiatCurrency: WalletCurrency? = null, + val minAmount: String? = null, + val maxAmount: String? = null, +) diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendFeature.kt new file mode 100644 index 000000000..f9aac9f0a --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendFeature.kt @@ -0,0 +1,646 @@ +package com.tonapps.deposit.screens.send + +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.ton.TonAddressTags +import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.blockchain.ton.extensions.isValidTonAddress +import com.tonapps.blockchain.tron.isValidTronAddress +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events +import com.tonapps.deposit.data.ExchangeRepository +import com.tonapps.deposit.screens.send.state.SendDestination +import com.tonapps.icu.Coins +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.MviSubject +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.rates.RatesRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.token.TokenRepository +import com.tonapps.wallet.data.token.entities.AccountTokenEntity +import com.tonapps.wallet.localization.Localization +import io.exchangeapi.infrastructure.ApiResult +import io.exchangeapi.models.CreateExchangeRequest +import io.exchangeapi.models.ExchangeFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +// TODO REMOVE ENTIRE SHITTY EXCHANGE DATA +interface SendModel { + sealed interface Action : MviAction { + data object Init : Action + data object Continue : Action + data object SetMax : Action + data class AddressInput(val address: String) : Action + data class AmountInput(val amount: String) : Action + data class CommentInput(val comment: String?) : Action + data class EncryptedCommentToggle(val enabled: Boolean) : Action + data class SelectToken(val token: TokenEntity) : Action + data class SelectTokenByAddress(val address: String) : Action + data object Swap : Action + } + + data class State( + val address: String = "", + val destination: SendDestination = SendDestination.Empty, + val isResolvingAddress: Boolean = false, + val isAddressLocked: Boolean = false, + + val amount: Coins = Coins.ZERO, + val selectedToken: AccountTokenEntity = AccountTokenEntity.EMPTY, + val isMaxAmount: Boolean = false, + val remainingTokenBalance: Coins = Coins.ZERO, + val availableTokens: List = emptyList(), + val hiddenBalance: Boolean = false, + val insufficientBalance: Boolean = false, + + val currency: WalletCurrency = WalletCurrency.DEFAULT, + val convertedAmount: Coins = Coins.ZERO, + val amountCurrency: Boolean = false, + + val comment: String? = null, + val isCommentAvailable: Boolean = true, + val isCommentEncrypted: Boolean = false, + val isMemoRequired: Boolean = false, + val commentError: Int? = null, + val encryptedCommentAvailable: Boolean = false, + + val isLedger: Boolean = false, + val isContinueEnabled: Boolean = false, + val isProcessing: Boolean = false, + val exchangeAsset: WalletCurrency? = null, + val exchangeMinAmount: Coins? = null, + val exchangeMaxAmount: Coins? = null, + val isNft: Boolean = false, + val tronSwapUrl: String? = null, + val tronSwapTitle: String? = null, + ) : MviState { + val isExchangeMode get() = exchangeAsset != null + } + + + class ViewState( + val global: MviProperty, + ) : MviViewState + + sealed interface Event { + data class NavigateToConfirm(val params: SendParams) : Event + data class ShowError(val message: String) : Event + data class UpdateAmount(val amount: Coins) : Event + data object ClearAmount : Event + } +} + +data class SendFeatureData( + val presetCurrency: WalletCurrency? = null, + val presetAddress: String? = null, + val sendExchangeData: SendExchangeData? = null, + val analyticsFrom: Events.SendNative.SendNativeFrom = Events.SendNative.SendNativeFrom.WalletScreen, +) + +@OptIn(FlowPreview::class) +class SendFeature( + private val data: SendFeatureData, + private val api: API, + private val accountRepository: AccountRepository, + private val settingsRepository: SettingsRepository, + private val tokenRepository: TokenRepository, + private val ratesRepository: RatesRepository, + private val exchangeRepository: ExchangeRepository, +) : MviFeature( + initState = SendModel.State(), + initAction = SendModel.Action.Init, +) { + + private val relay = MviRelay() + val events = relay.events + + private val addressSubject = MviSubject() + + // region Internal State + private lateinit var wallet: WalletEntity + private var nftAddress: String = "" + + private val isNft: Boolean get() = nftAddress.isNotBlank() + private val currency: WalletCurrency get() = settingsRepository.currency + private var tokens: List = emptyList() + private var tronAvailable: Boolean = false + + // endregion + + init { + addressSubject.events + .debounce { if (it.isEmpty()) 0L else 600L } + .distinctUntilChanged() + .mapLatest { address -> + stateScope.launch { + resolveAddress(address) + } + } + .launchIn(bgScope) + } + + override fun createViewState(): SendModel.ViewState { + return buildViewState { + SendModel.ViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: SendModel.Action) { + when (action) { + is SendModel.Action.Init -> handleInit() + is SendModel.Action.Continue -> handleContinue() + is SendModel.Action.SetMax -> handleSetMax() + is SendModel.Action.AddressInput -> handleAddressInput(action.address) + is SendModel.Action.AmountInput -> handleAmountInput(action.amount) + is SendModel.Action.CommentInput -> handleCommentInput(action.comment) + is SendModel.Action.EncryptedCommentToggle -> handleEncryptedCommentToggle(action.enabled) + is SendModel.Action.SelectToken -> handleSelectToken(action.token) + is SendModel.Action.SelectTokenByAddress -> handleSelectTokenByAddress(action.address) + is SendModel.Action.Swap -> handleSwap() + } + } + + // region Action Handlers + private suspend fun handleInit() { + AnalyticsHelper.Default.events.sendNative.sendOpen(from = data.analyticsFrom) + + val initData = withContext(Dispatchers.IO) { + wallet = accountRepository.getSelectedWallet() ?: return@withContext null + val fiatCurrency = settingsRepository.currency + + tokens = tokenRepository.get(fiatCurrency, wallet.accountId, wallet.network) + ?: emptyList() + + tronAvailable = tokens.any { it.isTrc20 } && settingsRepository.getTronUsdtEnabled(wallet.id) + + val selectedToken = if (data.presetCurrency != null) { + tokens.firstOrNull { it.address.equalsAddress(data.presetCurrency.address) } + } else { + null + } + ?: tokens.firstOrNull { it.isTon } + ?: AccountTokenEntity.EMPTY + + val isExchange = data.sendExchangeData != null + val config = api.getConfig(wallet.network) + InitData(selectedToken, fiatCurrency, isExchange, config.tronSwapUrl, config.tronSwapTitle) + } ?: return + + val (selectedToken, fiatCurrency, isExchange, tronSwapUrl, tronSwapTitle) = initData + + setState { + copy( + selectedToken = selectedToken, + currency = fiatCurrency, + hiddenBalance = settingsRepository.hiddenBalances, + isLedger = wallet.isLedger, + remainingTokenBalance = selectedToken.balance.uiBalance, + isCommentAvailable = !isExchange && !selectedToken.isTrc20, + availableTokens = tokens.filter { it.balance.isTransferable && !it.isTrx }, + isAddressLocked = data.presetAddress != null, + exchangeAsset = data.sendExchangeData?.exchangeTo, + isNft = nftAddress.isNotBlank(), + tronSwapUrl = tronSwapUrl, + tronSwapTitle = tronSwapTitle, + ) + } + + if (!data.presetAddress.isNullOrEmpty()) { + setState { copy(address = data.presetAddress, isResolvingAddress = true) } + resolveAddress(data.presetAddress) + } + + if (isExchange && data.sendExchangeData != null) { + setState { + copy( + exchangeMinAmount = data.sendExchangeData.minAmount?.toBigDecimalOrNull()?.let { Coins.of(it) }, + exchangeMaxAmount = data.sendExchangeData.maxAmount?.toBigDecimalOrNull()?.let { Coins.of(it) }, + ) + } + } + } + + private data class InitData( + val selectedToken: AccountTokenEntity, + val fiatCurrency: WalletCurrency, + val isExchange: Boolean, + val tronSwapUrl: String, + val tronSwapTitle: String, + ) + + private fun handleAddressInput(address: String) { + val state = obtainState() + if (state.isAddressLocked) return + + if (state.isExchangeMode) { + setState { + copy( + address = address, + destination = if (address.isNotEmpty()) { + SendDestination.ExternalAccount(address) + } else { + SendDestination.Empty + }, + isResolvingAddress = false, + ) + } + recalculateButtonEnabled() + return + } + + setState { + copy(address = address, isResolvingAddress = address.isNotEmpty()) + } + + addressSubject.emit(address) + } + + private suspend fun handleAmountInput(amount: String) { + setState { + copy(amount = Coins.of(amount, selectedToken.decimals), isMaxAmount = false) + } + + recalculateBalance() + } + + private fun handleCommentInput(comment: String?) { + setState { + val error = if (wallet.isLedger && !comment.isNullOrEmpty() && !comment.all { it.code in 32..126 }) { + Localization.ledger_comment_error + } else { + null + } + copy(comment = comment?.trim(), commentError = error) + } + recalculateButtonEnabled() + } + + private fun handleEncryptedCommentToggle(enabled: Boolean) { + setState { copy(isCommentEncrypted = enabled) } + } + + private suspend fun handleContinue() { + val state = obtainState() + if (state.isExchangeMode) { + handleExchangeContinue() + return + } + + val input = obtainState() + + AnalyticsHelper.Default.events.sendNative.sendClick( + from = data.analyticsFrom, + assetNetwork = input.selectedToken.balance.token.blockchain.id, + tokenSymbol = input.selectedToken.symbol, + amount = input.amount.value.toDouble(), + ) + + try { + val tokenAmount = getTokenAmount(input) + + val params = SendParams( + walletId = wallet.id, + destination = input.destination, + selectedToken = input.selectedToken, + tokenAmount = tokenAmount, + isMaxAmount = input.isMaxAmount, + comment = input.comment?.ifBlank { null }, + encryptedComment = input.isCommentEncrypted, + currency = currency, + nftAddress = nftAddress, + tokens = tokens, + tronAvailable = tronAvailable, + exchangeData = data.sendExchangeData?.let { + SendParams.Exchange( + currency = it.exchangeTo, + exchangeAddress = input.address, + withdrawalFee = it.withdrawalFee, + ) + }, + analyticsFrom = data.analyticsFrom, + ) + + relay.emit(SendModel.Event.NavigateToConfirm(params)) + } catch (e: Throwable) { + L.e("SendFeature", "Continue error", e) + relay.emit(SendModel.Event.ShowError(e.message ?: "Unknown error")) + } + } + + private suspend fun handleExchangeContinue() { + val input = obtainState() + val externalAddress = (input.destination as? SendDestination.ExternalAccount)?.address + ?: return + + setState { copy(isProcessing = true) } + + try { + val tokenAmount = getTokenAmount(input) + + val exchangeTo = data.sendExchangeData!!.exchangeTo + val apiResult = exchangeRepository.createExchange( + CreateExchangeRequest( + from = input.selectedToken.symbol, + to = exchangeTo.code, + wallet = externalAddress, + fromNetwork = data.presetCurrency?.network, + toNetwork = exchangeTo.network, + flow = ExchangeFlow.withdraw, + ) + ) + + val exchangeResult = when (apiResult) { + is ApiResult.Success -> apiResult.data + is ApiResult.Error -> { + setState { copy(isProcessing = false) } + relay.emit(SendModel.Event.ShowError(apiResult.message)) + return + } + } + + val payinAddress = exchangeResult.payinAddress + + // Resolve the exchange's payin address as a TON/TRON destination + val destination = withContext(Dispatchers.IO) { + if (tronAvailable && input.selectedToken.isTrc20 && payinAddress.isValidTronAddress()) { + SendDestination.TronAccount(payinAddress) + } else { + getDestinationAccount(payinAddress) + } + } + + if (destination !is SendDestination.TonAccount && destination !is SendDestination.TronAccount) { + throw IllegalStateException("Failed to resolve exchange address") + } + + val params = SendParams( + walletId = wallet.id, + destination = destination, + selectedToken = input.selectedToken, + tokenAmount = tokenAmount, + isMaxAmount = input.isMaxAmount, + comment = "Cross-chain withdrawal", + encryptedComment = false, + currency = currency, + nftAddress = nftAddress, + tokens = tokens, + tronAvailable = tronAvailable, + exchangeData = SendParams.Exchange( + currency = exchangeTo, + exchangeAddress = input.address, + estimatedDurationSeconds = exchangeResult.estimatedDuration, + withdrawalFee = data.sendExchangeData.withdrawalFee, + ), + analyticsFrom = data.analyticsFrom, + ) + + setState { copy(isProcessing = false) } + relay.emit(SendModel.Event.NavigateToConfirm(params)) + } catch (e: Throwable) { + L.e("SendFeature", "Exchange continue error", e) + setState { copy(isProcessing = false) } + relay.emit(SendModel.Event.ShowError(e.message ?: "Unknown error")) + } + } + + private suspend fun handleSetMax() { + val input = obtainState() + val maxAmount = if (input.amountCurrency) { + input.selectedToken.fiat + } else { + input.selectedToken.balance.uiBalance + } + + setState { copy(amount = maxAmount, isMaxAmount = true) } + relay.emit(SendModel.Event.UpdateAmount(maxAmount)) + recalculateBalance() + } + + private suspend fun handleSelectToken(token: TokenEntity) { + val accountToken = tokens.find { it.address.equalsAddress(token.address) } + ?: AccountTokenEntity.createEmpty(token, wallet.address) + + setState { + copy( + selectedToken = accountToken, + amount = Coins.of(0, accountToken.decimals), + isMaxAmount = false, + isCommentAvailable = !isExchangeMode && !accountToken.isTrc20, + remainingTokenBalance = accountToken.balance.uiBalance, + ) + } + relay.emit(SendModel.Event.ClearAmount) + + // Re-resolve address for token/blockchain mismatch + val input = obtainState() + if (input.address.isNotEmpty()) { + resolveAddress(input.address) + } + + recalculateBalance() + } + + private suspend fun handleSelectTokenByAddress(jettonAddress: String) { + val token = tokens.firstOrNull { it.address.equalsAddress(jettonAddress) } ?: return + handleSelectToken(token.token) + } + + // endregion + + // region Address Resolution + + private suspend fun resolveAddress(address: String) { + if (address.isEmpty()) { + setState { + copy( + destination = SendDestination.Empty, + isResolvingAddress = false, + isMemoRequired = false, + ) + } + recalculateButtonEnabled() + return + } + + setState { copy(isResolvingAddress = true) } + + val input = obtainState() + val selectedToken = input.selectedToken + + val destination = withContext(Dispatchers.IO) { + if (tronAvailable && address.isValidTronAddress()) { + if (selectedToken.isTrc20) { + SendDestination.TronAccount(address) + } else { + SendDestination.TokenError( + addressBlockchain = Blockchain.TRON, + selectedToken = selectedToken.token, + ) + } + } else { + val tonDest = getDestinationAccount(address) + if (tonDest is SendDestination.TonAccount && selectedToken.isTrc20) { + SendDestination.TokenError( + addressBlockchain = Blockchain.TON, + selectedToken = selectedToken.token, + ) + } else { + tonDest + } + } + } + + val memoRequired = (destination as? SendDestination.TonAccount)?.memoRequired == true + val existing = (destination as? SendDestination.TonAccount)?.existing == true + val encryptedAvailable = existing && !memoRequired && + (wallet.type == WalletType.Default || wallet.type == WalletType.Testnet || wallet.type == WalletType.Lockup) + + setState { + copy( + destination = destination, + isResolvingAddress = false, + isMemoRequired = memoRequired, + encryptedCommentAvailable = encryptedAvailable, + isCommentAvailable = !isExchangeMode && !selectedToken.isTrc20, + ) + } + + recalculateButtonEnabled() + } + + private suspend fun getDestinationAccount(userInput: String): SendDestination = withContext(Dispatchers.IO) { + val tonAddressTags = TonAddressTags.of(userInput) + if (tonAddressTags.userFriendly && tonAddressTags.isTestnet != wallet.testnet) { + return@withContext SendDestination.NotFound + } + + val accountDeferred = async { api.resolveAccount(userInput, wallet.network) } + val publicKeyDeferred = async { api.safeGetPublicKey(userInput, wallet.network) } + + val account = accountDeferred.await() ?: return@withContext SendDestination.NotFound + val publicKey = publicKeyDeferred.await() + + if (account.isScam == true) { + return@withContext SendDestination.Scam + } + + SendDestination.TonAccount( + userInput = userInput, + isUserInputAddress = userInput.isValidTonAddress(), + publicKey = publicKey, + account = account, + testnet = wallet.testnet, + tonAddressTags = tonAddressTags, + ) + } + + // endregion + + // region Balance Recalculation + private suspend fun recalculateBalance() { + val input = obtainState() + val token = input.selectedToken + val amount = input.amount + val amountCurrency = input.amountCurrency + val rates = ratesRepository.getRates(wallet.network, currency, token.address) + + val balance = if (amountCurrency) token.fiat else token.balance.uiBalance + val remaining = balance - amount + + val converted = if (amountCurrency) { + rates.convertFromFiat(token.address, amount) + } else { + rates.convert(token.address, amount) + } + + val remainingToken = if (amountCurrency) { + rates.convertFromFiat(token.address, token.fiat - amount) + } else { + token.balance.uiBalance - amount + } + + val insufficientBalance = if (remaining.isZero) false else remaining.isNegative + + setState { + copy( + remainingTokenBalance = remainingToken, + convertedAmount = converted.stripTrailingZeros(), + insufficientBalance = insufficientBalance, + ) + } + + recalculateButtonEnabled() + } + + private suspend fun handleSwap() { + val input = obtainState() + val convertedAmount = input.convertedAmount + + setState { + copy( + amountCurrency = !amountCurrency, + amount = convertedAmount, + ) + } + relay.emit(SendModel.Event.UpdateAmount(convertedAmount)) + recalculateBalance() + } + + private fun recalculateButtonEnabled() { + val input = obtainState() + val dest = input.destination + val amount = input.amount + val comment = input.comment + + val isValidDestination = dest is SendDestination.TonAccount + || dest is SendDestination.TronAccount + || (dest is SendDestination.ExternalAccount && dest.address.isNotEmpty()) + + val exchangeOutOfBounds = input.isExchangeMode && amount.isPositive && ( + (input.exchangeMinAmount != null && amount < input.exchangeMinAmount) || + (input.exchangeMaxAmount != null && amount > input.exchangeMaxAmount) + ) + + val enabled = when { + input.isProcessing -> false + !isValidDestination -> false + dest is SendDestination.TonAccount && dest.memoRequired && comment.isNullOrEmpty() -> false + input.commentError != null -> false + exchangeOutOfBounds -> false + isNft || (!input.insufficientBalance && amount.isPositive) -> true + else -> false + } + + setState { copy(isContinueEnabled = enabled) } + } + + // endregion + + // region Helpers + + private suspend fun getTokenAmount(input: SendModel.State): Coins { + if (!input.amountCurrency) return input.amount + val rates = ratesRepository.getRates(wallet.network, currency, input.selectedToken.address) + return rates.convertFromFiat(input.selectedToken.address, input.amount) + } + + // endregion +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendParams.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendParams.kt new file mode 100644 index 000000000..ccf2d3829 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendParams.kt @@ -0,0 +1,30 @@ +package com.tonapps.deposit.screens.send + +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.bus.generated.Events +import com.tonapps.deposit.screens.send.state.SendDestination +import com.tonapps.icu.Coins +import com.tonapps.wallet.data.token.entities.AccountTokenEntity + +data class SendParams( + val walletId: String, + val destination: SendDestination, + val selectedToken: AccountTokenEntity, + val tokenAmount: Coins, + val isMaxAmount: Boolean, + val comment: String?, + val encryptedComment: Boolean, + val currency: WalletCurrency, + val nftAddress: String, + val tokens: List, + val tronAvailable: Boolean, + val exchangeData: Exchange? = null, + val analyticsFrom: Events.SendNative.SendNativeFrom = Events.SendNative.SendNativeFrom.WalletScreen, +) { + data class Exchange( + val currency: WalletCurrency, + val exchangeAddress: String, + val estimatedDurationSeconds: Int? = null, + val withdrawalFee: String? = null, + ) +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendScreen.kt new file mode 100644 index 000000000..47985c990 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/SendScreen.kt @@ -0,0 +1,734 @@ +package com.tonapps.deposit.screens.send + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.core.deeplink.DeepLinkRoute +import com.tonapps.core.extensions.iconExternalUrl +import com.tonapps.core.helper.rememberClipboardManager +import com.tonapps.deposit.utils.AddressError +import com.tonapps.deposit.utils.rememberSendAddressState +import com.tonapps.deposit.utils.rememberSendAmountState +import com.tonapps.deposit.utils.rememberSendCommentState +import com.tonapps.icu.Coins +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.data.core.HIDDEN_BALANCE +import com.tonapps.wallet.localization.Localization +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import ui.components.moon.MoonBadgeButton +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonItemSubtitle +import ui.components.moon.MoonItemTitle +import ui.components.moon.MoonTopAppBar +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.cell.MoonTextFieldCell +import ui.components.moon.container.MoonScaffold +import ui.preview.ThemedPreview +import ui.theme.UIKit +import ui.utils.uppercased +import java.math.RoundingMode + +@Composable +fun SendScreen( + feature: SendFeature, + scannerResult: String? = null, + onClose: () -> Unit, + onBack: () -> Unit, + onAddressBook: ((onResult: (String) -> Unit) -> Unit)? = null, + onNavigateToConfirm: (SendParams) -> Unit = {}, + onShowError: (String) -> Unit = {}, + onNavigateToTokenPicker: (selectedTokenAddress: String?) -> Unit = {}, + onNavigateToScanner: () -> Unit = {}, +) { + val state by feature.state.global.observeSafeState() + + SendInputContent( + state = state, + events = feature.events, + scannerResult = scannerResult, + onClose = onClose, + onBack = onBack, + onAddressBook = onAddressBook, + onAddressInput = { feature.sendAction(SendModel.Action.AddressInput(it)) }, + onAmountInput = { feature.sendAction(SendModel.Action.AmountInput(it)) }, + onCommentInput = { feature.sendAction(SendModel.Action.CommentInput(it)) }, + onSetMax = { feature.sendAction(SendModel.Action.SetMax) }, + onSwap = { feature.sendAction(SendModel.Action.Swap) }, + onEncryptedCommentToggle = { feature.sendAction(SendModel.Action.EncryptedCommentToggle(it)) }, + onSelectTokenByAddress = { feature.sendAction(SendModel.Action.SelectTokenByAddress(it)) }, + onContinue = { feature.sendAction(SendModel.Action.Continue) }, + onNavigateToConfirm = onNavigateToConfirm, + onNavigateToTokenPicker = onNavigateToTokenPicker, + onNavigateToScanner = onNavigateToScanner, + onShowError = onShowError, + ) +} + +@SuppressLint("LocalContextGetResourceValueCall") +@Composable +private fun SendInputContent( + state: SendModel.State, + events: Flow, + scannerResult: String? = null, + onClose: () -> Unit, + onBack: () -> Unit, + onAddressBook: ((onResult: (String) -> Unit) -> Unit)? = null, + onAddressInput: (String) -> Unit, + onAmountInput: (String) -> Unit, + onCommentInput: (String?) -> Unit, + onSetMax: () -> Unit, + onSwap: () -> Unit, + onEncryptedCommentToggle: (Boolean) -> Unit, + onSelectTokenByAddress: (String) -> Unit, + onContinue: () -> Unit, + onNavigateToConfirm: (SendParams) -> Unit, + onShowError: (String) -> Unit, + onNavigateToTokenPicker: (selectedTokenAddress: String?) -> Unit, + onNavigateToScanner: () -> Unit = {}, +) { + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val keyboardManager = LocalSoftwareKeyboardController.current + + val addressState = rememberSendAddressState( + destination = state.destination, + isResolving = state.isResolvingAddress, + ) + + // Pre-fill locked address + LaunchedEffect(state.isAddressLocked, state.address) { + if (state.isAddressLocked && state.address.isNotEmpty() && addressState.text.isEmpty()) { + addressState.onTextChange(state.address) + } + } + + val commentState = rememberSendCommentState( + isLedger = state.isLedger, + ) + + val amountDecimals = if (state.amountCurrency) Coins.DEFAULT_DECIMALS else state.selectedToken.decimals + val amountState = rememberSendAmountState(amountDecimals) + + val expiredMessage = stringResource(Localization.expired_link) + LaunchedEffect(scannerResult) { + val value = scannerResult ?: return@LaunchedEffect + + val route = try { + DeepLinkRoute.resolve(value.toUri()) + } catch (_: Throwable) { + null + } + + val transfer = route as? DeepLinkRoute.Transfer + if (transfer != null) { + if (transfer.isExpired) { + onShowError(expiredMessage) + return@LaunchedEffect + } + + val address = transfer.address + addressState.onTextChange(address) + onAddressInput(address) + + val tokenDecimals = transfer.jettonAddress?.let { jettonAddr -> + onSelectTokenByAddress(jettonAddr) + state.availableTokens + .firstOrNull { it.address.equalsAddress(jettonAddr) } + ?.decimals + } ?: Coins.DEFAULT_DECIMALS + transfer.amount?.let { + amountState.setAmount(Coins.ofNano(it.toString(), tokenDecimals)) + onAmountInput(amountState.value) + } + transfer.text?.let { + commentState.onTextChange(it) + onCommentInput(it) + } + } + } + + LaunchedEffect(Unit) { + events.collect { event -> + when (event) { + is SendModel.Event.UpdateAmount -> amountState.setAmount(event.amount) + is SendModel.Event.ClearAmount -> amountState.clear() + is SendModel.Event.NavigateToConfirm -> onNavigateToConfirm(event.params) + is SendModel.Event.ShowError -> onShowError(event.message) + } + } + } + + Box(Modifier.fillMaxSize()) { + MoonScaffold( + Modifier + .fillMaxSize() + .imePadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .nestedScroll(rememberNestedScrollInteropConnection()), + topBar = { + val symbol = state.selectedToken.symbol + val isToken = !state.selectedToken.isTon && !state.selectedToken.isTrx + val blockchainId = state.selectedToken.blockchain.id + val secondaryColor = UIKit.colorScheme.text.secondary + + val titleText = if (state.isExchangeMode) { + val receiveSymbol = state.exchangeAsset?.symbol.orEmpty() + val receiveType = state.exchangeAsset?.tokenType?.fmt + remember(receiveSymbol, receiveType, secondaryColor) { + buildAnnotatedString { + append("Receive $receiveSymbol") + if (receiveType != null) { + append(" ") + withStyle(SpanStyle(color = secondaryColor)) { + append(receiveType) + } + } + } + } + } else { + val sendText = stringResource(Localization.send) + remember(sendText, symbol, isToken, blockchainId, secondaryColor) { + buildAnnotatedString { + append("$sendText $symbol") + if (isToken) { + append(" ") + withStyle(SpanStyle(color = secondaryColor)) { + append(blockchainId) + } + } + } + } + } + + MoonTopAppBar( + title = "", + actionIconRes = UIKitIcon.ic_close_16, + onActionClick = onClose, + navigationIconRes = UIKitIcon.ic_chevron_left_16, + onNavigationClick = onBack, + ignoreSystemOffset = true, + showDivider = false, + backgroundColor = Color.Transparent, + content = { + Text( + text = titleText, + style = UIKit.typography.h3, + color = UIKit.colorScheme.text.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + ) + }, + ) { + val addressStr = stringResource(Localization.address) + val addressHintStr = stringResource(Localization.address_hint) + // Address input + MoonTextFieldCell( + value = addressState.text, + onValueChange = { + addressState.onTextChange(it) + onAddressInput(it) + }, + hint = remember(state.exchangeAsset, state.selectedToken.symbol, addressStr, addressHintStr) { + state.exchangeAsset + ?.let { "$addressStr ${it.symbol} ${it.tokenType?.fmt.orEmpty()}" } + ?: addressHintStr + }, + modifier = Modifier.focusRequester(focusRequester), + enabled = !state.isAddressLocked, + isError = addressState.isError, + loading = addressState.isResolving, + keyboardOptions = remember { KeyboardOptions(imeAction = ImeAction.Next) }, + keyboardActions = remember { + KeyboardActions(onNext = { + focusManager.moveFocus( + FocusDirection.Down + ) + }) + }, + maxLines = 2, + trailingAction = if (state.isAddressLocked) { + null + } else { + { + if (addressState.text.isEmpty()) { + val clipboard = rememberClipboardManager() + MoonBadgeButton( + text = stringResource(Localization.paste), + ) { + clipboard.getText()?.let { + addressState.onTextChange(it) + onAddressInput(it) + } + + focusManager.moveFocus(FocusDirection.Next) + } + + if (!state.isExchangeMode) { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_qr_viewfinder_outline_28), + onClick = { + onNavigateToScanner() + focusManager.clearFocus(true) + }, + color = UIKit.colorScheme.accent.blue, + ) + + if (onAddressBook != null) { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_address_book_28), + color = UIKit.colorScheme.accent.blue, + onClick = { + focusManager.clearFocus(true) + onAddressBook { selectedAddress -> + addressState.onTextChange(selectedAddress) + onAddressInput(selectedAddress) + focusManager.moveFocus(FocusDirection.Next) + } + } + ) + } + } + } + } + }, + ) + + // Address error + if (addressState.isError) { + val addressError = addressState.error + MoonItemSubtitle( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + text = when (addressError) { + is AddressError.NotFound -> stringResource(Localization.invalid_address) + is AddressError.Scam -> stringResource(Localization.scam_address_error) + is AddressError.TokenMismatch -> stringResource( + Localization.send_wrong_blockchain, + addressError.addressBlockchain.id, + ) + null -> "" + }, + color = UIKit.colorScheme.accent.red, + ) + + // Swap link for USDT blockchain mismatch + if (addressError is AddressError.TokenMismatch && + (addressError.selectedToken.isUsdt || addressError.selectedToken.isUsdtTrc20) && + !state.tronSwapUrl.isNullOrEmpty() + ) { + val uriHandler = LocalUriHandler.current + val fromNetwork = state.selectedToken.blockchain.id + val toNetwork = addressError.addressBlockchain.id + val swapTitle = state.tronSwapTitle.orEmpty() + val accentBlue = UIKit.colorScheme.accent.blue + val swapFullText = stringResource( + Localization.send_wrong_blockchain_swap, + fromNetwork, + toNetwork, + swapTitle, + ) + val swapAnnotated = remember(swapFullText, swapTitle, accentBlue) { + buildAnnotatedString { + val titleStart = swapFullText.indexOf(swapTitle) + if (titleStart >= 0) { + append(swapFullText.substring(0, titleStart)) + withStyle(SpanStyle(color = accentBlue)) { + append(swapTitle) + } + append(swapFullText.substring(titleStart + swapTitle.length)) + } else { + append(swapFullText) + } + } + } + MoonItemSubtitle( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 4.dp) + .clickable { uriHandler.openUri(state.tronSwapUrl) }, + text = swapAnnotated, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + if (!state.isNft) { + // Amount input + MoonTextFieldCell( + value = amountState.textFieldValue, + onValueChange = { + amountState.onTextFieldValueChange(it) + onAmountInput(amountState.value) + }, + hint = stringResource(Localization.amount), + singleLine = true, + disableClearButton = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + trailingAction = { + val secondaryColor = UIKit.colorScheme.text.secondary + val badgeText = remember(state.exchangeAsset, state.selectedToken, secondaryColor) { + if (state.exchangeAsset != null) { + buildAnnotatedString { + append(state.exchangeAsset.symbol) + val type = state.exchangeAsset.tokenType + if (type != null) { + append(" ") + withStyle(SpanStyle(color = secondaryColor)) { + append(type.fmt) + } + } + } + } else { + buildAnnotatedString { + append(state.selectedToken.symbol) + val type = state.selectedToken.token.tokenType + if (type != null) { + append(" ") + withStyle(SpanStyle(color = secondaryColor)) { + append(type.fmt) + } + } + } + } + } + + val context = LocalContext.current + MoonBadgeButton( + content = { + if (state.exchangeAsset != null) { + MoonItemImage( + modifier = Modifier.size(24.dp), + image = state.exchangeAsset.iconExternalUrl(context).toString(), + ) + } else { + MoonItemImage( + modifier = Modifier.size(24.dp), + image = state.selectedToken.imageUri.toString(), + ) + } + + MoonItemTitle(text = badgeText) + if (state.availableTokens.size > 1 && !state.isExchangeMode) { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_switch_16), + color = UIKit.colorScheme.icon.primary + ) + } + }, + onClick = if (state.availableTokens.size > 1 && !state.isExchangeMode) { + { + focusManager.clearFocus(true) + keyboardManager?.hide() + onNavigateToTokenPicker(state.selectedToken.address) + } + } else { + null + }, + ) + }, + keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }), + ) + + // Balance and exchange rate row + Row( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Converted amount + val convertedCode = if (state.amountCurrency) { + state.selectedToken.symbol + } else { + state.currency.code + } + val convertedFormatted = remember(state.convertedAmount, convertedCode) { + val value = CurrencyFormatter.format( + value = state.convertedAmount, + roundingMode = RoundingMode.DOWN, + ) + if (state.amountCurrency) { + "$value $convertedCode" + } else { + "$convertedCode $value" + } + } + if (state.isExchangeMode) { + val sendSymbol = state.selectedToken.symbol + val receiveSymbol = state.exchangeAsset?.symbol.orEmpty() + MoonItemSubtitle( + text = "1 $sendSymbol = 1 $receiveSymbol", + ) + } else { + Row( + modifier = Modifier.clickable { onSwap() }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MoonItemSubtitle(text = convertedFormatted) + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_swap_vertical_16), + size = 16.dp, + ) + } + } + + // Balance + Max + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (state.hiddenBalance) { + MoonItemSubtitle(text = HIDDEN_BALANCE) + } else if (state.insufficientBalance) { + MoonItemSubtitle( + text = stringResource(Localization.insufficient_balance), + color = UIKit.colorScheme.accent.red, + ) + } else { + val remainingFormatted = CurrencyFormatter.format( + currency = state.selectedToken.symbol, + value = state.remainingTokenBalance, + roundingMode = RoundingMode.DOWN, + replaceSymbol = false, + ) + MoonItemSubtitle( + text = stringResource( + Localization.remaining_balance, + remainingFormatted + ), + color = UIKit.colorScheme.text.secondary, + ) + } + + if (!state.insufficientBalance) { + MoonItemSubtitle( + text = stringResource(Localization.max).uppercased(), + color = UIKit.colorScheme.text.accent, + modifier = Modifier.clickable { onSetMax() }, + ) + } + } + } + + // Exchange min/max restriction + if (state.isExchangeMode) { + val exchangeError = + remember(state.amount, state.exchangeMinAmount, state.exchangeMaxAmount) { + when { + state.exchangeMinAmount != null && state.amount.isPositive && state.amount < state.exchangeMinAmount -> state.exchangeMinAmount + .let { CurrencyFormatter.format(value = it).toString() } + .let { "min" to it } + + state.exchangeMaxAmount != null && state.amount.isPositive && state.amount > state.exchangeMaxAmount -> state.exchangeMaxAmount + .let { CurrencyFormatter.format(value = it).toString() } + .let { "max" to it } + + else -> null + } + } + if (exchangeError != null) { + MoonItemSubtitle( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + text = if (exchangeError.first == "min") { + stringResource(Localization.min_amount, exchangeError.second) + } else { + stringResource(Localization.max_amount, exchangeError.second) + }, + color = UIKit.colorScheme.accent.red, + ) + } + } + } // end if (!state.isNft) + + // Comment input + if (state.isCommentAvailable) { + Spacer(Modifier.height(16.dp)) + + val isEncrypted = state.isCommentEncrypted + val hasCommentText = commentState.text.isNotBlank() + + MoonTextFieldCell( + value = commentState.text, + onValueChange = { + commentState.onTextChange(it) + onCommentInput(it) + }, + hint = when { + state.isMemoRequired -> stringResource(Localization.required_comment) + isEncrypted -> stringResource(Localization.encrypted_comment) + else -> stringResource(Localization.comment) + }, + isError = commentState.isError, + hintColor = if (isEncrypted && hasCommentText) { + UIKit.colorScheme.accent.green + } else { + null + }, + activeBorderColor = if (isEncrypted) { + UIKit.colorScheme.accent.green + } else { + null + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + ) + + // Comment error + if (commentState.isError) { + MoonItemSubtitle( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + text = stringResource(Localization.ledger_comment_error), + color = UIKit.colorScheme.accent.red, + maxLines = 3, + ) + } + + // Memo required indicator + if (state.isMemoRequired) { + MoonItemSubtitle( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + text = stringResource(Localization.send_request_comment), + color = UIKit.colorScheme.accent.orange, + maxLines = 3, + ) + } + + // Encryption toggle hint + if (state.encryptedCommentAvailable && hasCommentText) { + CommentEncryptionHint( + isEncrypted = isEncrypted, + onToggle = { onEncryptedCommentToggle(!isEncrypted) }, + ) + } + } + + DisposableEffect(Unit) { + if (!state.isAddressLocked) { + focusRequester.requestFocus() + } + onDispose { + focusRequester.freeFocus() + } + } + + Spacer(Modifier.weight(1f)) + + MoonButtonCell( + text = stringResource(Localization.continue_action), + enabled = state.isContinueEnabled && !state.isProcessing, + ) { + onContinue() + } + } + } +} + +@Composable +private fun CommentEncryptionHint( + isEncrypted: Boolean, + onToggle: () -> Unit, +) { + Column( + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 4.dp) + .fillMaxWidth(), + ) { + MoonItemSubtitle( + text = if (isEncrypted) { + stringResource(Localization.comment_encrypted_hint) + } else { + stringResource(Localization.comment_decrypted_hint) + }, + ) + + MoonItemSubtitle( + text = if (isEncrypted) { + stringResource(Localization.decrypt_comment) + } else { + stringResource(Localization.encrypt_comment) + }, + color = UIKit.colorScheme.accent.blue, + modifier = Modifier.clickable(onClick = onToggle), + ) + } +} + +@Preview +@Composable +private fun SendScreenPreview() { + ThemedPreview { + SendInputContent( + state = SendModel.State(), + events = flowOf(), + onClose = {}, + onBack = {}, + onAddressBook = null, + onAddressInput = {}, + onAmountInput = {}, + onCommentInput = {}, + onSetMax = {}, + onSwap = {}, + onEncryptedCommentToggle = {}, + onSelectTokenByAddress = {}, + onContinue = {}, + onNavigateToConfirm = {}, + onNavigateToTokenPicker = {}, + onShowError = {} + ) + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/ContactFeature.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/ContactFeature.kt new file mode 100644 index 000000000..44588b0fa --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/ContactFeature.kt @@ -0,0 +1,230 @@ +package com.tonapps.deposit.screens.send.contact + +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.ton.extensions.toRawAddress +import com.tonapps.blockchain.ton.extensions.toUserFriendly +import com.tonapps.extensions.short8 +import com.tonapps.legacy.enteties.WalletExtendedEntity +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.uikit.list.ListCell +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.battery.BatteryRepository +import com.tonapps.wallet.data.contacts.ContactsRepository +import com.tonapps.wallet.data.contacts.entities.ContactEntity +import com.tonapps.wallet.data.events.EventsRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +sealed interface ContactAction : MviAction { + data object Init : ContactAction + data class HideContact(val address: String) : ContactAction + data class DeleteContact(val contact: ContactEntity) : ContactAction +} + +sealed interface ContactState : MviState { + data object Loading : ContactState + data class Data(val items: List) : ContactState +} + +class ContactViewState( + val global: MviProperty, +) : MviViewState + +sealed interface ContactEvent { + data class ContactSelected(val result: SendContactResult) : ContactEvent +} + +class ContactFeature( + private val wallet: WalletEntity, + private val accountRepository: AccountRepository, + private val settingsRepository: SettingsRepository, + private val contactsRepository: ContactsRepository, + private val eventsRepository: EventsRepository, + private val batteryRepository: BatteryRepository, +) : MviFeature( + initState = ContactState.Loading, + initAction = ContactAction.Init, +) { + + private val relay = MviRelay() + val events: Flow = relay.events + + override fun createViewState(): ContactViewState { + return buildViewState { + ContactViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: ContactAction) { + when (action) { + is ContactAction.Init -> loadContacts() + is ContactAction.HideContact -> hideContact(action.address) + is ContactAction.DeleteContact -> deleteContact(action.contact) + } + } + + fun selectContact(item: ContactItem.AddressItem) { + val type = when (item) { + is ContactItem.MyWallet -> SendContactResult.MY_WALLET_TYPE + is ContactItem.LatestContact -> SendContactResult.CONTACT_TYPE + is ContactItem.SavedContact -> SendContactResult.CONTACT_TYPE + } + relay.emit(ContactEvent.ContactSelected(SendContactResult(type, item.address))) + } + + private suspend fun loadContacts() = withContext(Dispatchers.IO) { + try { + val myWallets = getMyWallets() + + val savedContactsFlow = contactsRepository.contactsFlow + .map { contacts -> + contacts.filter { it.testnet == wallet.testnet } + .mapIndexed { index, contact -> + val position = ListCell.getPosition(contacts.size, index) + ContactItem.SavedContact(position, contact, wallet.testnet) + } + } + + val latestContactsFlow = flow> { + emit(emptyList()) + emitAll(getLatestContactsFlow()) + } + + stateScope.launch { + combine(savedContactsFlow, latestContactsFlow) { saved, latest -> + buildItems(myWallets, saved, latest) + }.collect { items -> + setState { ContactState.Data(items) } + } + } + } catch (e: Throwable) { + L.e(e) + setState { ContactState.Data(emptyList()) } + } + } + + private fun buildItems( + myWallets: List, + savedContacts: List, + latestContacts: List, + ): List { + val items = mutableListOf() + if (myWallets.isNotEmpty()) { + items.addAll(myWallets) + items.add(ContactItem.Space) + } + if (savedContacts.isNotEmpty()) { + items.addAll(savedContacts) + items.add(ContactItem.Space) + } + if (latestContacts.isNotEmpty()) { + items.addAll(latestContacts) + items.add(ContactItem.Space) + } + return items + } + + private fun hideContact(address: String) { + contactsRepository.hide(address.toRawAddress(), wallet.network) + } + + private suspend fun deleteContact(contact: ContactEntity) { + withContext(Dispatchers.IO) { + contactsRepository.deleteContact(contact.id) + } + } + + private suspend fun getMyWallets(): List { + val wallets = accountRepository.getWallets() + .filter { it.type != WalletType.Watch && it.network == wallet.network && it.address != wallet.address } + .map { WalletExtendedEntity(it, settingsRepository.getWalletPrefs(it.id)) } + .sortedBy { it.index } + .map { it.raw } + + return wallets.mapIndexed { index, w -> + val position = ListCell.getPosition(wallets.size, index) + ContactItem.MyWallet(position, w) + } + } + + private val tronEnabled: Boolean + get() = settingsRepository.getTronUsdtEnabled(wallet.id) + + private val tronLatestTransactionsFlow = flow { + if (!tronEnabled) { + emit(emptyList()) + return@flow + } + val tronAddress = if (wallet.hasPrivateKey && !wallet.testnet && tronEnabled) { + accountRepository.getTronAddress(wallet.id) + } else null + val tonProofToken = accountRepository.requestTonProofToken(wallet) + + if (tronAddress != null && tonProofToken != null) { + emit(eventsRepository.tronLatestSentTransactions(tronAddress, tonProofToken)) + } else { + emit(emptyList()) + } + }.flowOn(Dispatchers.IO) + + private val latestTronContactsFlow = combine( + contactsRepository.hiddenFlow, + tronLatestTransactionsFlow, + ) { _, events -> + events.filter { + !contactsRepository.isHidden(it.to, wallet.network) + }.mapIndexed { index, event -> + val position = ListCell.getPosition(events.size, index) + ContactItem.LatestContact(position, event.to, event.to.short8, event.timestamp.value) + } + } + + private fun getLatestContactsFlow() = combine( + contactsRepository.hiddenFlow, + eventsRepository.latestRecipientsFlow( + accountId = wallet.accountId, + network = wallet.network, + ), + latestTronContactsFlow, + ) { _, recipients, tronContacts -> + val gasProxyAddresses = batteryRepository.getConfig(wallet.network).gasProxy + val tonContacts = recipients.filter { + !contactsRepository.isHidden(it.account.address.toRawAddress(), wallet.network) + }.mapIndexed { index, recipient -> + val position = ListCell.getPosition(recipients.size, index) + ContactItem.LatestContact( + position = position, + address = recipient.account.address.toUserFriendly(testnet = wallet.testnet), + name = recipient.account.name + ?: recipient.account.address.toUserFriendly(testnet = wallet.testnet).short8, + timestamp = recipient.timestamp, + ) + }.filter { + it.address !in gasProxyAddresses + } + + val contacts = (tonContacts + tronContacts).sortedByDescending { + it.timestamp + }.take(6) + + contacts.mapIndexed { index, item -> + item.copy(position = ListCell.getPosition(contacts.size, index)) + } + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/ContactItem.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/ContactItem.kt new file mode 100644 index 000000000..33683ddd8 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/ContactItem.kt @@ -0,0 +1,42 @@ +package com.tonapps.deposit.screens.send.contact + +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.uikit.list.ListCell +import com.tonapps.wallet.data.contacts.entities.ContactEntity + +sealed interface ContactItem { + + sealed interface AddressItem : ContactItem { + val position: ListCell.Position + val address: String + } + + data class MyWallet( + override val position: ListCell.Position, + val wallet: WalletEntity, + ) : AddressItem { + override val address: String get() = wallet.address + val emoji: CharSequence get() = wallet.label.emoji + val name: String get() = wallet.label.name + } + + data class SavedContact( + override val position: ListCell.Position, + val contact: ContactEntity, + val testnet: Boolean, + ) : AddressItem { + override val address: String get() = contact.address + val name: String get() = contact.name + } + + data class LatestContact( + override val position: ListCell.Position, + override val address: String, + val name: String, + val timestamp: Long, + ) : AddressItem + + data object Space : ContactItem + + data object Loading : ContactItem +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/SendContactResult.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/SendContactResult.kt new file mode 100644 index 000000000..537606f8d --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/SendContactResult.kt @@ -0,0 +1,11 @@ +package com.tonapps.deposit.screens.send.contact + +data class SendContactResult( + val type: Int, + val address: String, +) { + companion object { + const val MY_WALLET_TYPE = 1 + const val CONTACT_TYPE = 2 + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/SendContactsScreen.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/SendContactsScreen.kt new file mode 100644 index 000000000..c0cfc2755 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/contact/SendContactsScreen.kt @@ -0,0 +1,140 @@ +package com.tonapps.deposit.screens.send.contact + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.MoonTopAppBarSimple +import ui.components.moon.cell.MoonButtonCell +import ui.components.moon.container.MoonSurface + +// TODO +@Composable +fun SendContactsScreen( + feature: ContactFeature, + onClose: () -> Unit, + onAddContact: () -> Unit, + onEditContact: (contactId: Long) -> Unit, + onContactSelected: (SendContactResult) -> Unit, +) { + val state by feature.state.global.observeSafeState() + + LaunchedEffect(Unit) { + feature.events.collect { event -> + when (event) { + is ContactEvent.ContactSelected -> onContactSelected(event.result) + } + } + } + + MoonSurface { + Column( + Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + MoonTopAppBarSimple( + title = stringResource(Localization.contacts), + actionIconRes = UIKitIcon.ic_close_16, + onActionClick = onClose, + backgroundColor = Color.Transparent, + ) + + LazyColumn(modifier = Modifier.weight(1f)) { + when (val s = state) { + is ContactState.Loading -> { + item { ContactLoadingCell() } + } + is ContactState.Data -> { + items(s.items, key = { itemKey(it) }) { item -> + when (item) { + is ContactItem.MyWallet -> MyWalletCell( + item = item, + onClick = { feature.selectContact(item) }, + ) + is ContactItem.SavedContact -> SavedContactCell( + item = item, + onClick = { feature.selectContact(item) }, + onEdit = { onEditContact(item.contact.id) }, + onDelete = { feature.sendAction(ContactAction.DeleteContact(item.contact)) }, + ) + is ContactItem.LatestContact -> LatestContactCell( + item = item, + onClick = { feature.selectContact(item) }, + onAddToContacts = { onAddContact() }, + onHide = { feature.sendAction(ContactAction.HideContact(item.address)) }, + ) + is ContactItem.Space -> ContactSpaceCell() + is ContactItem.Loading -> ContactLoadingCell() + } + } + } + } + } + + MoonButtonCell( + text = stringResource(Localization.add_contact), + ) { + onAddContact() + } + } + } +} + +private fun itemKey(item: ContactItem): Any = when (item) { + is ContactItem.MyWallet -> "wallet_${item.address}" + is ContactItem.SavedContact -> "saved_${item.contact.id}" + is ContactItem.LatestContact -> "latest_${item.address}" + is ContactItem.Space -> "space_${item.hashCode()}" + is ContactItem.Loading -> "loading" +} + +@Composable +fun MyWalletCell( + item: ContactItem.MyWallet, + onClick: () -> Unit, +) { + // TODO: Implement +} + +@Composable +fun SavedContactCell( + item: ContactItem.SavedContact, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + // TODO: Implement +} + +@Composable +fun LatestContactCell( + item: ContactItem.LatestContact, + onClick: () -> Unit, + onAddToContacts: () -> Unit, + onHide: () -> Unit, +) { + // TODO: Implement +} + +@Composable +fun ContactSpaceCell() { + // TODO: Implement +} + +@Composable +fun ContactLoadingCell() { + // TODO: Implement +} + diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendAmountState.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendAmountState.kt new file mode 100644 index 000000000..400b941c4 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendAmountState.kt @@ -0,0 +1,13 @@ +package com.tonapps.deposit.screens.send.state + +import com.tonapps.icu.Coins + +data class SendAmountState( + val remainingFormat: CharSequence = "", + val convertedFormat: CharSequence = "", + val converted: Coins = Coins.ZERO, + val insufficientBalance: Boolean = false, + val currencyCode: String = "", + val amountCurrency: Boolean = false, + val hiddenBalance: Boolean = false, +) \ No newline at end of file diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendConfig.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendConfig.kt new file mode 100644 index 000000000..cc07855ef --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendConfig.kt @@ -0,0 +1,6 @@ +package com.tonapps.deposit.screens.send.state + +data class SendConfig( + val amountFiat: Boolean = false, + val max: Boolean = false +) \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/SendDestination.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendDestination.kt similarity index 92% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/SendDestination.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendDestination.kt index 78dbfdf1d..859e1dfb0 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/SendDestination.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendDestination.kt @@ -1,8 +1,8 @@ -package com.tonapps.tonkeeper.ui.screen.send.main.state +package com.tonapps.deposit.screens.send.state +import com.tonapps.blockchain.contract.Blockchain import com.tonapps.blockchain.ton.TonAddressTags -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import io.tonapi.models.AccountStatus import org.ton.api.pub.PublicKeyEd25519 import org.ton.block.AddrStd @@ -84,6 +84,8 @@ sealed class SendDestination { ) } + data class ExternalAccount(val address: String) : SendDestination() + data object Scam : SendDestination() data object Empty : SendDestination() data object NotFound : SendDestination() diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendFee.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendFee.kt new file mode 100644 index 000000000..eb337c91f --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendFee.kt @@ -0,0 +1,75 @@ +package com.tonapps.deposit.screens.send.state + +import com.tonapps.blockchain.model.legacy.Fee +import com.tonapps.icu.Coins +import com.tonapps.blockchain.model.legacy.TransferEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import io.batteryapi.models.EstimatedTronTx +import org.ton.block.AddrStd + +sealed class SendFee { + + interface TokenFee { + val amount: Fee + val fiatAmount: Coins + val fiatCurrency: WalletCurrency + } + + interface Extra { + val extra: Long + } + + interface RelayerFee { + val excessesAddress: AddrStd + } + + data class Ton( + override val amount: Fee, + override val fiatAmount: Coins, + override val fiatCurrency: WalletCurrency, + val error: Throwable? = null + ) : SendFee(), TokenFee + + data class Gasless( + override val amount: Fee, + override val fiatAmount: Coins, + override val fiatCurrency: WalletCurrency, + override val excessesAddress: AddrStd, + ) : SendFee(), TokenFee, RelayerFee + + data class Battery( + val charges: Int, + val chargesBalance: Int, + override val extra: Long, + override val excessesAddress: AddrStd, + val fiatAmount: Coins, + val fiatCurrency: WalletCurrency, + val estimatedTron: EstimatedTronTx? = null, + val excessCharges: Long? = null, + ) : SendFee(), RelayerFee, Extra { + val enoughCharges : Boolean + get() = chargesBalance >= charges + } + + data class TronTrx( + override val amount: Fee, + override val fiatAmount: Coins, + override val fiatCurrency: WalletCurrency, + val balance: Coins, + ) : SendFee(), TokenFee { + val enoughBalance : Boolean + get() = balance >= amount.value + } + + data class TronTon( + override val amount: Fee, + override val fiatAmount: Coins, + override val fiatCurrency: WalletCurrency, + val sendToAddress: String, + val balance: Coins, + ) : SendFee(), TokenFee { + val enoughBalance : Boolean + get() = balance >= amount.value + TransferEntity.POINT_ONE_TON + } + +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/SendTransaction.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendTransaction.kt similarity index 75% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/SendTransaction.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendTransaction.kt index b14aa9b39..cf889550d 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/SendTransaction.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/SendTransaction.kt @@ -1,8 +1,8 @@ -package com.tonapps.tonkeeper.ui.screen.send.main.state +package com.tonapps.deposit.screens.send.state import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.WalletEntity data class SendTransaction( val fromWallet: WalletEntity, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/TonTransaction.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/TonTransaction.kt similarity index 76% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/TonTransaction.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/TonTransaction.kt index 4059674d4..4619e8528 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/send/main/state/TonTransaction.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/screens/send/state/TonTransaction.kt @@ -1,8 +1,8 @@ -package com.tonapps.tonkeeper.ui.screen.send.main.state +package com.tonapps.deposit.screens.send.state import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.WalletEntity data class TonTransaction( val fromWallet: WalletEntity, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/ContractExecutionConfig.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/ContractExecutionConfig.kt similarity index 96% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/ContractExecutionConfig.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/ContractExecutionConfig.kt index 9c0dba084..a47256b97 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/ContractExecutionConfig.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/ContractExecutionConfig.kt @@ -1,4 +1,4 @@ -package com.tonapps.tonkeeper.usecase.emulation +package com.tonapps.deposit.usecase.emulation import io.tonapi.models.BlockchainConfig diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/Emulated.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/Emulated.kt new file mode 100644 index 000000000..9d034aa4f --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/Emulated.kt @@ -0,0 +1,95 @@ +package com.tonapps.deposit.usecase.emulation + +import com.tonapps.icu.Coins +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.blockchain.model.legacy.Fee +import com.tonapps.deposit.screens.send.state.SendFee +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.wallet.data.battery.BatteryMapper +import com.tonapps.wallet.data.battery.BatteryRepository +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.wallet.data.core.entity.TransferType +import com.tonapps.wallet.data.rates.RatesRepository +import io.tonapi.models.MessageConsequences +import kotlin.math.abs + +data class Emulated( + val consequences: MessageConsequences?, + val type: TransferType, + val total: Total, + val extra: Extra, + val currency: WalletCurrency, + val failed: Boolean = false, + val error: Throwable? = null, +) { + + companion object { + val defaultExtra = Extra(false, Coins.ONE, Coins.ONE) + + suspend fun Emulated.buildFee( + wallet: WalletEntity, + api: API, + accountRepository: AccountRepository, + batteryRepository: BatteryRepository, + ratesRepository: RatesRepository + ): SendFee { + return if (withBattery && consequences != null) { + val extra = consequences.event.extra + val chargesBalance = 0 // TODO REQ BatteryHelper.getBatteryCharges(wallet, accountRepository, batteryRepository) + val batteryConfig = batteryRepository.getConfig(wallet.network) + val charges = BatteryMapper.calculateChargesAmount( + Coins.of(abs(extra)).value, + batteryConfig.chargeCost + ) + val excessesAddress = batteryConfig.excessesAddress + val rates = ratesRepository.getTONRates(wallet.network, currency) + val converted = rates.convertTON(Coins.of(abs(extra))) + SendFee.Battery( + charges = charges, + chargesBalance = chargesBalance, + extra = extra, + excessesAddress = excessesAddress!!, + fiatAmount = converted, + fiatCurrency = currency, + ) + } else { + val fee = Fee(extra.value, extra.isRefund) + val rates = ratesRepository.getTONRates(wallet.network, currency) + val converted = rates.convertTON(fee.value) + SendFee.Ton( + amount = fee, + fiatAmount = converted, + fiatCurrency = currency, + ) + } + } + } + + val nftCount: Int + get() = total.nftCount + + val totalFormat: CharSequence + get() = CurrencyFormatter.format(currency.code, total.totalFiat) + + val withBattery: Boolean + get() = type == TransferType.Battery || type == TransferType.Gasless + + val totalFees: Coins + get() = consequences?.let { + Coins.of(it.trace.transaction.totalFees) + } ?: Coins.ZERO + + data class Total( + val totalFiat: Coins, + val nftCount: Int, + val isDangerous: Boolean, + ) + + data class Extra( + val isRefund: Boolean, + val value: Coins, + val fiat: Coins, + ) +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/EmulationContractExecution.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/EmulationContractExecution.kt similarity index 93% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/EmulationContractExecution.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/EmulationContractExecution.kt index ca1d8acd1..a3417c770 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/EmulationContractExecution.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/EmulationContractExecution.kt @@ -1,9 +1,10 @@ -package com.tonapps.tonkeeper.usecase.emulation +package com.tonapps.deposit.usecase.emulation import com.tonapps.blockchain.ton.contract.WalletVersion +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.icu.Coins import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import io.tonapi.models.Account import io.tonapi.models.AccountStatus import kotlinx.coroutines.Dispatchers @@ -14,9 +15,9 @@ import java.math.BigDecimal import kotlin.math.ceil class EmulationContractExecution(private val api: API) { - private suspend fun getConfig(testnet: Boolean): ContractExecutionConfig = + private suspend fun getConfig(network: TonNetwork): ContractExecutionConfig = withContext(Dispatchers.IO) { - val config = api.blockchain(testnet).getBlockchainConfig() + val config = api.blockchain(network).getBlockchainConfig() ContractExecutionConfig(config) } @@ -105,9 +106,9 @@ class EmulationContractExecution(private val api: API) { withContext( Dispatchers.IO ) { - val config = getConfig(wallet.testnet) + val config = getConfig(wallet.network) - val nowTimestamp = api.liteServer(wallet.testnet).getRawTime().time + val nowTimestamp = api.liteServer(wallet.network).getRawTime().time val isInited = account.status != AccountStatus.uninit && account.status != AccountStatus.nonexist val timeDelta = nowTimestamp - account.lastActivity @@ -151,7 +152,7 @@ class EmulationContractExecution(private val api: API) { withContext( Dispatchers.IO ) { - val config = getConfig(wallet.testnet) + val config = getConfig(wallet.network) var msgBits = 0 var msgCells = 0 diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/EmulationUseCase.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/EmulationUseCase.kt new file mode 100644 index 000000000..2d0bdd3b2 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/EmulationUseCase.kt @@ -0,0 +1,384 @@ +package com.tonapps.deposit.usecase.emulation + +import com.tonapps.blockchain.ton.AndroidSecureRandom +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.icu.Coins +import com.tonapps.icu.Coins.Companion.sumOf +import com.tonapps.wallet.api.API +import com.tonapps.blockchain.model.legacy.BalanceEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.wallet.api.tron.TronApi +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.blockchain.model.legacy.MessageBodyEntity +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.wallet.data.battery.BatteryRepository +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.wallet.api.extensions.toTokenEntity +import com.tonapps.wallet.data.core.entity.TransferType +import com.tonapps.wallet.data.rates.RatesRepository +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.token.TokenRepository +import io.tonapi.models.JettonQuantity +import io.tonapi.models.MessageConsequences +import io.tonapi.models.Risk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.ton.api.pk.PrivateKeyEd25519 +import org.ton.cell.Cell +import org.ton.contract.wallet.WalletTransfer +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.abs + +class EmulationUseCase( + private val ratesRepository: RatesRepository, + private val settingsRepository: SettingsRepository, + private val accountRepository: AccountRepository, + private val batteryRepository: BatteryRepository, + private val api: API, + private val tokenRepository: TokenRepository, + private val delegate: Delegate, +) { + + interface Delegate { // TODO remove + suspend fun getTotalBalance(wallet: WalletEntity, currency: WalletCurrency): Coins? + } + + private val contractExecution = EmulationContractExecution(api) + + suspend operator fun invoke( + message: MessageBodyEntity, + useBattery: Boolean = false, + forceRelayer: Boolean = false, + checkTonBalance: Boolean = false, + params: Boolean = false, + ): Emulated { + return try { + if (forceRelayer || useBattery) { + emulateWithBattery( + message = message, + forceRelayer = forceRelayer, + ) + } else { + emulate(message, params, checkTonBalance) + } + } catch (e: Throwable) { + Emulated( + consequences = null, + total = Emulated.Total(Coins.ZERO, 0, false), + extra = Emulated.defaultExtra, + currency = settingsRepository.currency, + failed = true, + type = TransferType.Default, + error = e, + ) + } + } + + suspend operator fun invoke( + wallet: WalletEntity, + seqNo: Int, + unsignedBody: Cell, + outMsgs: List, + forwardAmount: Coins, + ): Emulated { + return try { + emulate(wallet, seqNo, unsignedBody, outMsgs, forwardAmount) + } catch (e: Throwable) { + Emulated( + consequences = null, + total = Emulated.Total(Coins.ZERO, 0, false), + extra = Emulated.defaultExtra, + currency = settingsRepository.currency, + failed = true, + type = TransferType.Default, + error = e, + ) + } + } + + private fun createMessage( + message: MessageBodyEntity, + internalMessage: Boolean + ): Cell { + return message.createSignedBody( + privateKey = PrivateKeyEd25519(AndroidSecureRandom), + internalMessage = internalMessage + ) + } + + private suspend fun emulateWithBattery( + message: MessageBodyEntity, + forceRelayer: Boolean, + ): Emulated { + if (api.getConfig(message.wallet.network).batterySendDisabled) { + throw IllegalStateException("Battery is disabled") + } + + val wallet = message.wallet + val tonProofToken = accountRepository.requestTonProofToken(wallet) + ?: throw IllegalStateException("Can't find TonProof token") + val boc = createMessage(message, true) + + val result = batteryRepository.emulate( + tonProofToken = tonProofToken, + publicKey = wallet.publicKey, + network = wallet.network, + boc = boc, + forceRelayer = forceRelayer, + safeModeEnabled = settingsRepository.isSafeModeEnabled(wallet.network) + ) ?: throw IllegalStateException("Failed to emulate battery") + + return parseEmulated(wallet, result.consequences, TransferType.Battery) + } + + private suspend fun emulate( + wallet: WalletEntity, + seqNo: Int, + unsignedBody: Cell, + outMsgs: List, + forwardAmount: Coins, + ): Emulated { + val signedBoc = wallet.sign( + privateKey = PrivateKeyEd25519(AndroidSecureRandom), + seqNo = seqNo, + body = unsignedBody + ) + + val account = api.accounts(wallet.network).getAccount(wallet.accountId) + val accountBalance = Coins.of(account.balance) + val totalFee = contractExecution.computeRemoveExtensionFee(wallet, signedBoc, outMsgs) + val totalAmount = + totalFee + forwardAmount + if (totalAmount > accountBalance) { + throw InsufficientBalanceError(accountBalance, totalAmount) + } + + val consequences = api.emulate( + cell = signedBoc, + network = wallet.network, + safeModeEnabled = settingsRepository.isSafeModeEnabled(wallet.network) + ) ?: throw IllegalArgumentException("Emulation failed") + return parseEmulated(wallet, consequences, TransferType.Default) + } + + private suspend fun emulate( + message: MessageBodyEntity, + params: Boolean, + checkTonBalance: Boolean + ): Emulated { + val wallet = message.wallet + val boc = createMessage(message, false) + + if (checkTonBalance) { + val account = api.accounts(wallet.network).getAccount(wallet.accountId) + val accountBalance = Coins.of(account.balance) + val totalFee = contractExecution.computeFee(wallet, account, boc, message.getOutMsgs()) + val totalAmount = + totalFee + message.transfers.sumOf { Coins.of(it.coins.coins.toString()) } + if (totalAmount > accountBalance) { + throw InsufficientBalanceError(accountBalance, totalAmount) + } + } + + val consequences = (if (params) { + api.emulate( + cell = boc, + network = wallet.network, + address = wallet.address, + balance = ((Coins.ONE + Coins.ONE) + calculateTransferAmount(message.transfers)).toLong(), + safeModeEnabled = settingsRepository.isSafeModeEnabled(wallet.network) + ) + } else { + api.emulate( + cell = boc, + network = wallet.network, + safeModeEnabled = settingsRepository.isSafeModeEnabled(wallet.network) + ) + }) ?: throw IllegalArgumentException("Emulation failed") + return parseEmulated(wallet, consequences, TransferType.Default) + } + + private suspend fun parseEmulated( + wallet: WalletEntity, + consequences: MessageConsequences, + transferType: TransferType, + currency: WalletCurrency = settingsRepository.currency, + ): Emulated { + val total = getTotal(wallet, consequences.risk, currency) + val extra = getExtra(wallet.network, consequences.event.extra, currency) + return Emulated( + consequences = consequences, + type = transferType, + total = total, + extra = extra, + currency = currency, + ) + } + + private suspend fun getTotal( + wallet: WalletEntity, + risk: Risk, + currency: WalletCurrency, + ): Emulated.Total { + val balanceFiat = delegate.getTotalBalance(wallet, currency) ?: Coins.ZERO + val ton = tokenRepository.getTON(currency, wallet.accountId, wallet.network, true) + val tonValue = if (risk.transferAllRemainingBalance) { + ton?.balance?.value?.toLong() ?: risk.ton + } else { + risk.ton + } + val tokens = getTokens(wallet, tonValue, risk.jettons) + val rates = ratesRepository.getRates(wallet.network, currency, tokens.map { it.token.address }) + val totalFiat = tokens.map { token -> + rates.convert(token.token.address, token.value) + }.sumOf { it } + + val diff = if (balanceFiat > Coins.ZERO) { + totalFiat.value / balanceFiat.value + } else { + totalFiat.value + } + + return Emulated.Total( + totalFiat = totalFiat, + nftCount = risk.nfts.size, + isDangerous = diff >= BigDecimal("0.2") + ) + } + + private suspend fun getExtra( + network: TonNetwork, + extra: Long, + currency: WalletCurrency, + ): Emulated.Extra { + val value = Coins.of(abs(extra)) + val rates = ratesRepository.getTONRates(network, currency) + val fiat = rates.convertTON(value) + + return Emulated.Extra( + isRefund = extra >= 0, + value = value, + fiat = fiat, + ) + } + + private fun getTokens( + wallet: WalletEntity, + tonValue: Long, + jettons: List + ): List { + val list = mutableListOf() + list.add( + BalanceEntity.create( + accountId = wallet.address, + value = Coins.of(tonValue), + ) + ) + for (jettonQuantity in jettons) { + val token = jettonQuantity.jetton.toTokenEntity() + val value = Coins.ofNano(jettonQuantity.quantity, token.decimals) + list.add( + BalanceEntity( + token = token, + value = value, + walletAddress = jettonQuantity.walletAddress.address + ) + ) + } + return list.toList() + } + + private suspend fun getBatteryCharges(wallet: WalletEntity): Int = withContext(Dispatchers.IO) { + accountRepository.requestTonProofToken(wallet)?.let { + batteryRepository.getCharges(it, wallet.publicKey, wallet.network) + } ?: 0 + } + + suspend fun getTrc20TransferDefaultFees( + wallet: WalletEntity, + currency: WalletCurrency, + emulation: TronFeesEmulation? = null, + ): Trc20TransferDefaultFees { + val config = batteryRepository.getConfig(wallet.network) + val tokens = tokenRepository.get(currency, wallet.accountId, wallet.network) + val tonBalance = tokens?.find { it.isTon }?.balance?.value ?: Coins.ZERO + val trxBalance = tokens?.find { it.isTrx }?.balance?.value ?: Coins.ZERO + val chargesBalance = getBatteryCharges(wallet) + + val tonAmount = emulation?.ton ?: Coins.of(config.meanPrices.tonMeanPriceTronUsdt.toBigDecimal()) + val tonFiat = ratesRepository.getTONRates(wallet.network, currency) + .convertTON(tonAmount) + val tonAvailableTransfers = tonBalance.divide(tonAmount, RoundingMode.FLOOR).value.toInt() + + val charges = emulation?.batteryCharges ?: config.meanPrices.batteryMeanPriceTronUsdt + val chargesTon = config.chargeCost.toBigDecimal().multiply(charges.toBigDecimal()) + val chargesFiat = ratesRepository.getTONRates(wallet.network, currency) + .convertTON(Coins.of(chargesTon)) + val chargesAvailableTransfers = chargesBalance / charges + + val trxFee = emulation?.trx ?: getCachedTrxFee(api.tron) + val trxFiat = ratesRepository.getRates(wallet.network, currency, TokenEntity.TRX.address) + .convert(TokenEntity.TRX.address, trxFee) + val trxAvailableTransfers = trxBalance.divide(trxFee, RoundingMode.FLOOR).value.toInt() + + val result = Trc20TransferDefaultFees( + totalAvailableTransfers = chargesAvailableTransfers + tonAvailableTransfers + trxAvailableTransfers, + currency = currency, + batteryFee = Trc20TransferDefaultFees.BatteryFee( + balance = chargesBalance, + charges = charges, + fiatAmount = chargesFiat, + availableTransfers = chargesAvailableTransfers + ), + tonFee = Trc20TransferDefaultFees.TonFee( + balance = tonBalance, + amount = tonAmount, + fiatAmount = tonFiat, + availableTransfers = tonAvailableTransfers + ), + trxFee = Trc20TransferDefaultFees.TrxFee( + balance = trxBalance, + amount = trxFee, + fiatAmount = trxFiat, + availableTransfers = trxAvailableTransfers + ), + ) + return result + } + + private suspend fun getCachedTrxFee(api: TronApi): Coins { + val now = System.currentTimeMillis() + synchronized(cacheLock) { + val cached = cachedTrxFee + if (cached != null && now - cachedTrxFeeTimestamp < TRX_FEE_TTL_MS) { + return cached + } + } + + val newValue = api.getBurnTrxAmountForResources(api.transferDefaultResources) + synchronized(cacheLock) { + cachedTrxFee = newValue + cachedTrxFeeTimestamp = now + } + + return newValue + } + + private fun calculateTransferAmount(transfers: List): Coins { + return transfers.sumOf { + Coins.of(it.coins.coins.amount.toLong()) + } + } + + companion object { + private const val TRX_FEE_TTL_MS = 10 * 60 * 1000L + private val cacheLock = Any() + + @Volatile + private var cachedTrxFee: Coins? = null + @Volatile + private var cachedTrxFeeTimestamp: Long = 0L + + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/InsufficientBalanceError.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/InsufficientBalanceError.kt similarity index 81% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/InsufficientBalanceError.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/InsufficientBalanceError.kt index 1e2d3f0f7..b838a7fb7 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/emulation/InsufficientBalanceError.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/InsufficientBalanceError.kt @@ -1,4 +1,4 @@ -package com.tonapps.tonkeeper.usecase.emulation +package com.tonapps.deposit.usecase.emulation import com.tonapps.icu.Coins diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/Trc20TransferDefaultFees.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/Trc20TransferDefaultFees.kt new file mode 100644 index 000000000..e73c1675a --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/Trc20TransferDefaultFees.kt @@ -0,0 +1,33 @@ +package com.tonapps.deposit.usecase.emulation + +import com.tonapps.icu.Coins +import com.tonapps.blockchain.model.legacy.WalletCurrency + +data class Trc20TransferDefaultFees( + val batteryFee: BatteryFee, + val tonFee: TonFee, + val trxFee: TrxFee, + val currency: WalletCurrency, + val totalAvailableTransfers: Int +) { + data class BatteryFee( + val balance: Int, + val charges: Int, + val fiatAmount: Coins, + val availableTransfers: Int + ) + + data class TonFee( + val balance: Coins, + val amount: Coins, + val fiatAmount: Coins, + val availableTransfers: Int + ) + + data class TrxFee( + val balance: Coins, + val amount: Coins, + val fiatAmount: Coins, + val availableTransfers: Int + ) +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/TronFeesEmulation.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/TronFeesEmulation.kt new file mode 100644 index 000000000..a7c20dd4c --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/emulation/TronFeesEmulation.kt @@ -0,0 +1,12 @@ +package com.tonapps.deposit.usecase.emulation + +import android.os.Parcelable +import com.tonapps.icu.Coins +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TronFeesEmulation( + val ton: Coins?, + val trx: Coins?, + val batteryCharges: Int?, +): Parcelable \ No newline at end of file diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignException.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignException.kt new file mode 100644 index 000000000..662654c45 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignException.kt @@ -0,0 +1,10 @@ +package com.tonapps.deposit.usecase.sign + +import com.tonapps.blockchain.model.legacy.WalletType + +sealed class SignException(message: String): Exception(message) { + + data class UnsupportedWalletType( + val type: WalletType + ): SignException("Unsupported wallet type: $type") +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/sign/SignProof.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignProof.kt similarity index 76% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/sign/SignProof.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignProof.kt index a55982496..941bd8e2f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/sign/SignProof.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignProof.kt @@ -1,14 +1,9 @@ -package com.tonapps.tonkeeper.usecase.sign +package com.tonapps.deposit.usecase.sign -import com.tonapps.base64.encodeBase64 import com.tonapps.blockchain.ton.connect.TONProof import com.tonapps.blockchain.ton.connect.TONProof.Request -import com.tonapps.tonkeeper.extensions.requestPrivateKey -import com.tonapps.tonkeeper.ui.screen.external.qr.keystone.sign.KeystoneSignScreen -import com.tonapps.tonkeeper.ui.screen.ledger.proof.LedgerProofScreen -import com.tonapps.tonkeeper.ui.screen.send.main.SendException import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.passcode.PasscodeManager import com.tonapps.wallet.data.rn.RNLegacy import com.tonapps.wallet.localization.Localization @@ -18,6 +13,11 @@ import uikit.navigation.NavigationActivity import java.util.concurrent.CancellationException import com.tonapps.blockchain.ton.connect.TCDomain import com.tonapps.blockchain.ton.connect.TCAddress +import com.tonapps.core.requestPrivateKey +import com.tonapps.deposit.screens.send.SendException +import io.ktor.util.encodeBase64 +import uikit.base.BaseFragment +import java.math.BigInteger class SignProof( private val accountRepository: AccountRepository, @@ -25,21 +25,34 @@ class SignProof( private val rnLegacy: RNLegacy, ) { + interface Delegate { + fun openLedgerScreen( + domain: String, + timestamp: BigInteger, + payload: String, + walletId: String + ): Pair + } + suspend fun ledger( activity: NavigationActivity, wallet: WalletEntity, payload: String, domain: String, ): TONProof.Result { + if (activity !is Delegate) { + throw IllegalArgumentException("Activity must implement SignTransaction.Delegate") + } + val timestamp = System.currentTimeMillis() / 1000L - val fragment = LedgerProofScreen.newInstance( + val (fragment, key) = activity.openLedgerScreen( domain = domain, timestamp = timestamp.toBigInteger(), payload = payload, walletId = wallet.id ) val result = activity.addForResult(fragment) - val signature = result.getByteArray(LedgerProofScreen.SIGNED_PROOF) + val signature = result.getByteArray(key) if (signature == null || signature.isEmpty()) { throw CancellationException("Ledger cancelled") } @@ -58,20 +71,24 @@ class SignProof( payload: String, domain: String ): TONProof.Result { + if (activity !is SignTransaction.Delegate) { + throw IllegalArgumentException("Activity must implement SignTransaction.Delegate") + } + val request = Request( payload = payload, domain = TCDomain(domain), address = TCAddress(wallet.contract.address) ) - val fragment = KeystoneSignScreen.newInstance( + val (fragment, contract) = activity.showKeystoneSignScreen( unsignedBody = hex(request.message), isTransaction = false, address = wallet.address, keystone = wallet.keystone ?: throw IllegalArgumentException("Keystone is not set") ) val result = activity.addForResult(fragment) - val signature = fragment.contract.parseResult(result) + val signature = contract.parseResult(result) return TONProof.Result( timestamp = request.timestamp, diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignTransaction.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignTransaction.kt new file mode 100644 index 000000000..53e56c7d7 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignTransaction.kt @@ -0,0 +1,222 @@ +package com.tonapps.deposit.usecase.sign + +import android.content.Context +import android.net.Uri +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.ton.SignatureDomain +import com.tonapps.blockchain.ton.extensions.hex +import com.tonapps.blockchain.tron.TronTransaction +import com.tonapps.core.requestPrivateKey +import com.tonapps.core.sign +import com.tonapps.deposit.screens.send.SendException +import com.tonapps.ledger.ton.Transaction +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.passcode.PasscodeManager +import com.tonapps.wallet.data.rn.RNLegacy +import com.tonapps.wallet.localization.Localization +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.ton.api.pub.PublicKeyEd25519 +import org.ton.bitstring.BitString +import org.ton.boc.BagOfCells +import org.ton.cell.Cell +import uikit.base.BaseFragment +import uikit.extensions.addForResult +import uikit.navigation.NavigationActivity +import java.util.UUID +import java.util.concurrent.CancellationException + +class SignTransaction( + private val accountRepository: AccountRepository, + private val passcodeManager: PasscodeManager, + private val rnLegacy: RNLegacy, +) { + + interface Delegate { + fun showLedgerSignScreen( + transaction: Transaction, + walletId: String, + transactionIndex: Int, + transactionCount: Int + ): Pair + + fun showKeystoneSignScreen( + requestId: String = UUID.randomUUID().toString(), + unsignedBody: String, + isTransaction: Boolean, + address: String, + keystone: WalletEntity.Keystone, + ): Pair> + + fun newInstance( + publicKey: PublicKeyEd25519, + unsignedBody: Cell, + label: String = "" + ): Pair> + + suspend fun invoke( + context: Context, + publicKey: PublicKeyEd25519, + body: Cell, + ): BitString? + } + + suspend fun tron( + activity: NavigationActivity, + wallet: WalletEntity, + transaction: TronTransaction, + ): TronTransaction { + if (!wallet.hasPrivateKey) { + throw SendException.UnableSendTransaction() + } + val isValidPasscode = passcodeManager.confirmation(activity, activity.getString(Localization.app_name)) + if (!isValidPasscode) { + throw CancellationException() + } + + val privateKey = accountRepository.getTronPrivateKey(wallet.id) + ?: throw SendException.UnableSendTransaction() + + return transaction.sign(privateKey) + } + + suspend fun ledger( + activity: NavigationActivity, + wallet: WalletEntity, + ledgerTransaction: Transaction, + transactionIndex: Int, + transactionCount: Int + ): Cell { + if (activity !is Delegate) { + throw IllegalArgumentException("Activity must implement SignTransaction.Delegate") + } + + val (fragment, key) = activity.showLedgerSignScreen( + transaction = ledgerTransaction, + walletId = wallet.id, + transactionIndex = transactionIndex, + transactionCount = transactionCount + ) + val result = activity.addForResult(fragment) + val signerMessage = result.getByteArray(key) + if (signerMessage == null || signerMessage.isEmpty()) { + throw CancellationException("Ledger cancelled") + } + return BagOfCells(signerMessage).first() + } + + suspend fun requestSignature( + activity: NavigationActivity, + wallet: WalletEntity, + unsignedBody: Cell + ): BitString { + return when (wallet.type) { + WalletType.SignerQR -> signerQR(activity, wallet, unsignedBody) + WalletType.Signer -> signerApp(activity, wallet, unsignedBody) + WalletType.Default, WalletType.Tetra, WalletType.Testnet, WalletType.Lockup -> default( + activity, + wallet, + unsignedBody + ) + + WalletType.Keystone -> keystone(activity, wallet, unsignedBody) + else -> { + throw IllegalArgumentException("Unsupported wallet type: ${wallet.type}") + } + } + } + + suspend fun requestSignedMessage( + activity: NavigationActivity, + wallet: WalletEntity, + unsignedBody: Cell + ): Cell { + val signature = requestSignature(activity, wallet, unsignedBody) + return wallet.contract.signedBody(signature, unsignedBody) + } + + private suspend fun keystone( + activity: NavigationActivity, + wallet: WalletEntity, + unsignedBody: Cell + ): BitString { + if (activity !is Delegate) { + throw IllegalArgumentException("Activity must implement SignTransaction.Delegate") + } + + val (fragment, contract) = activity.showKeystoneSignScreen( + unsignedBody = unsignedBody.hex(), + isTransaction = true, + address = wallet.address, + keystone = wallet.keystone ?: throw IllegalArgumentException("Keystone is not set") + ) + val result = activity.addForResult(fragment) + return contract.parseResult(result) + } + + private suspend fun signerQR( + activity: NavigationActivity, + wallet: WalletEntity, + unsignedBody: Cell + ): BitString { + if (activity !is Delegate) { + throw IllegalArgumentException("Activity must implement SignTransaction.Delegate") + } + + val (fragment, contract) = activity.newInstance( + publicKey = wallet.publicKey, + unsignedBody = unsignedBody + ) + val result = activity.addForResult(fragment) + return contract.parseResult(result) + } + + private suspend fun signerApp( + activity: NavigationActivity, + wallet: WalletEntity, + unsignedBody: Cell + ): BitString { + if (activity !is Delegate) { + throw IllegalArgumentException("Activity must implement SignTransaction.Delegate") + } + + val hash = activity.invoke(activity, wallet.publicKey, unsignedBody) + return hash ?: throw CancellationException("Signer cancelled") + } + + suspend fun default( + activity: NavigationActivity, + wallet: WalletEntity, + unsignedBody: Cell + ): BitString = withContext(Dispatchers.IO) { + if (!wallet.hasPrivateKey) { + throw SendException.UnableSendTransaction() + } + val isValidPasscode = passcodeManager.confirmation(activity, activity.getString(Localization.app_name)) + if (!isValidPasscode) { + throw CancellationException() + } + val privateKey = accountRepository.requestPrivateKey(activity, rnLegacy, wallet.id) + ?: throw SendException.UnableSendTransaction() + val hash = unsignedBody.hash().toByteArray() + val dataToSign = wallet.contract.signatureGlobalId?.let { SignatureDomain.prefixedHash(it, hash) } ?: hash + BitString(privateKey.sign(dataToSign)) + } + + suspend fun default( + activity: NavigationActivity, + wallet: WalletEntity, + bytes: ByteArray + ): ByteArray = withContext(Dispatchers.IO) { + if (!wallet.hasPrivateKey) { + throw SendException.UnableSendTransaction() + } + val isValidPasscode = passcodeManager.confirmation(activity, activity.getString(Localization.app_name)) + if (!isValidPasscode) { + throw CancellationException() + } + + accountRepository.sign(activity, rnLegacy, wallet.id, bytes) + } +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/sign/SignUseCase.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignUseCase.kt similarity index 93% rename from apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/sign/SignUseCase.kt rename to apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignUseCase.kt index 4b7ceeb17..9ba579a7e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/usecase/sign/SignUseCase.kt +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/usecase/sign/SignUseCase.kt @@ -1,12 +1,13 @@ -package com.tonapps.tonkeeper.usecase.sign +package com.tonapps.deposit.usecase.sign import android.content.Context import com.tonapps.blockchain.ton.connect.TONProof import com.tonapps.blockchain.tron.TronTransaction import com.tonapps.ledger.ton.Transaction import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.Wallet -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.deposit.usecase.sign.SignTransaction.Delegate import com.tonapps.wallet.data.passcode.PasscodeManager import com.tonapps.wallet.data.rn.RNLegacy import kotlinx.coroutines.Dispatchers @@ -14,7 +15,6 @@ import kotlinx.coroutines.withContext import org.ton.bitstring.BitString import org.ton.cell.Cell import uikit.extensions.activity -import uikit.navigation.NavigationActivity class SignUseCase( private val accountRepository: AccountRepository, @@ -41,9 +41,9 @@ class SignUseCase( payload: String, ): TONProof.Result = withContext(Dispatchers.Main) { val activity = context.activity ?: throw IllegalArgumentException("Context must be an Activity") - if (wallet.type == Wallet.Type.Keystone) { + if (wallet.type == WalletType.Keystone) { signProof.keystone(activity, wallet, payload, domain) - } else if (wallet.type == Wallet.Type.Ledger) { + } else if (wallet.type == WalletType.Ledger) { signProof.ledger(activity, wallet, payload, domain) } else if (wallet.hasPrivateKey) { signProof.default(activity, wallet, payload, domain) diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/FormatDuration.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/FormatDuration.kt new file mode 100644 index 000000000..95926ba9d --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/FormatDuration.kt @@ -0,0 +1,15 @@ +package com.tonapps.deposit.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.tonapps.wallet.localization.Localization + +@Composable +fun formatDuration(seconds: Int): String { + return if (seconds < 60) { + stringResource(Localization.up_to_duration, seconds.toString(), stringResource(Localization.time_seconds)) + } else { + val minutes = seconds / 60 + stringResource(Localization.up_to_duration, minutes.toString(), stringResource(Localization.time_minutes)) + } +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/ProviderAmountTextValidator.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/ProviderAmountTextValidator.kt new file mode 100644 index 000000000..52a3655c0 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/ProviderAmountTextValidator.kt @@ -0,0 +1,124 @@ +package com.tonapps.deposit.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.tonapps.deposit.screens.provider.ProviderItem +import com.tonapps.icu.Coins + +sealed interface AmountError { + data class BelowMin(val minAmount: Coins) : AmountError + data class AboveMax(val maxAmount: Coins) : AmountError +} + +@Stable +class ProviderAmountState internal constructor( + private var provider: ProviderItem? = null, +) { + var value by mutableStateOf(TextFieldValue()) + private set + + val text: String + get() = value.text + + var coins: Coins by mutableStateOf(Coins.ZERO) + private set + + var error: AmountError? by mutableStateOf(null) + private set + + val hasAmount: Boolean + get() = coins.isPositive + + val isValid: Boolean + get() = hasAmount && error == null + + fun onValueChange(newValue: TextFieldValue) { + val sanitized = sanitizeAmount(newValue.text) + val selection = if (sanitized.length != newValue.text.length) { + TextRange(sanitized.length) + } else { + newValue.selection + } + value = TextFieldValue(text = sanitized, selection = selection) + coins = parseAmount(sanitized) + error = validate(coins, provider) + } + + fun onTextChange(newText: String) { + onValueChange(TextFieldValue(text = newText, selection = TextRange(newText.length))) + } + + internal fun updateProvider(newProvider: ProviderItem?) { + provider = newProvider + error = validate(coins, newProvider) + } + + companion object { + val Saver: Saver = Saver( + save = { it.text }, + restore = { saved -> ProviderAmountState().also { it.onTextChange(saved) } }, + ) + + private fun sanitizeAmount(input: String): String { + val filtered = input.filter { it.isDigit() || it == '.' || it == ',' } + if (filtered.isEmpty()) return "" + + val sb = StringBuilder() + var hasSeparator = false + for (c in filtered) { + if (c == '.' || c == ',') { + if (!hasSeparator) { + hasSeparator = true + sb.append(c) + } + } else { + sb.append(c) + } + } + + // prepend 0 if starts with separator: ".123" -> "0.123" + if (sb.isNotEmpty() && (sb[0] == '.' || sb[0] == ',')) { + sb.insert(0, '0') + } + + return sb.toString() + } + + private fun parseAmount(text: String): Coins { + return try { + val coins = Coins.of(text) + if (coins.isPositive) coins else Coins.ZERO + } catch (_: Throwable) { + Coins.ZERO + } + } + + private fun validate(coins: Coins, provider: ProviderItem?): AmountError? { + if (!coins.isPositive || provider == null) return null + return when { + provider.minAmount.isPositive && coins < provider.minAmount -> AmountError.BelowMin(provider.minAmount) + provider.maxAmount != null && coins > provider.maxAmount -> AmountError.AboveMax(provider.maxAmount) + else -> null + } + } + } +} + +@Composable +fun rememberProviderAmountState( + provider: ProviderItem?, +): ProviderAmountState { + val state = rememberSaveable(saver = ProviderAmountState.Saver) { ProviderAmountState(provider) } + LaunchedEffect(provider) { + state.updateProvider(provider) + } + return state +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendAddressState.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendAddressState.kt new file mode 100644 index 000000000..0fab607da --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendAddressState.kt @@ -0,0 +1,81 @@ +package com.tonapps.deposit.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.deposit.screens.send.state.SendDestination + +sealed interface AddressError { + data object NotFound : AddressError + data object Scam : AddressError + data class TokenMismatch( + val addressBlockchain: Blockchain, + val selectedToken: TokenEntity, + ) : AddressError +} + +@Stable +class SendAddressState internal constructor() { + + var text by mutableStateOf("") + private set + + var error: AddressError? by mutableStateOf(null) + private set + + var isResolving by mutableStateOf(false) + private set + + val hasAddress: Boolean + get() = text.isNotBlank() + + val isError: Boolean + get() = error != null + + fun onTextChange(newText: String) { + text = newText + if (newText.isEmpty()) { + error = null + isResolving = false + } + } + + internal fun updateDestination(destination: SendDestination, isResolving: Boolean) { + this.isResolving = isResolving + error = when (destination) { + is SendDestination.NotFound -> AddressError.NotFound + is SendDestination.Scam -> AddressError.Scam + is SendDestination.TokenError -> AddressError.TokenMismatch( + addressBlockchain = destination.addressBlockchain, + selectedToken = destination.selectedToken, + ) + else -> null + } + } + + companion object { + val Saver: Saver = Saver( + save = { it.text }, + restore = { saved -> SendAddressState().also { it.onTextChange(saved) } }, + ) + } +} + +@Composable +fun rememberSendAddressState( + destination: SendDestination, + isResolving: Boolean, +): SendAddressState { + val state = rememberSaveable(saver = SendAddressState.Saver) { SendAddressState() } + LaunchedEffect(destination, isResolving) { + state.updateDestination(destination, isResolving) + } + return state +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendAmountState.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendAmountState.kt new file mode 100644 index 000000000..c9b31003d --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendAmountState.kt @@ -0,0 +1,100 @@ +package com.tonapps.deposit.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.tonapps.icu.Coins + +@Stable +class SendAmountState internal constructor() { + + var textFieldValue by mutableStateOf(TextFieldValue("")) + private set + + var decimals: Int = Coins.DEFAULT_DECIMALS + internal set + + val value: String get() = textFieldValue.text + + fun onValueChange(newValue: String) { + onTextFieldValueChange(textFieldValue.copy(text = newValue)) + } + + fun onTextFieldValueChange(newValue: TextFieldValue) { + val sanitized = sanitizeAmount(newValue.text, decimals) + val selection = if (sanitized.length != newValue.text.length) { + TextRange(sanitized.length) + } else { + newValue.selection + } + textFieldValue = TextFieldValue(text = sanitized, selection = selection) + } + + fun setAmount(amount: Coins) { + val formatted = amount.value.stripTrailingZeros().toPlainString() + textFieldValue = TextFieldValue( + text = formatted, + selection = TextRange(formatted.length), + ) + } + + fun clear() { + textFieldValue = TextFieldValue("") + } + + companion object { + val Saver: Saver = Saver( + save = { it.value }, + restore = { saved -> + SendAmountState().also { + it.textFieldValue = TextFieldValue( + text = saved, + selection = TextRange(saved.length), + ) + } + }, + ) + + private fun sanitizeAmount(input: String, maxDecimals: Int): String { + val filtered = input.filter { it.isDigit() || it == '.' || it == ',' } + if (filtered.isEmpty()) return "" + + val sb = StringBuilder() + var hasSeparator = false + var decimalCount = 0 + for (c in filtered) { + if (c == '.' || c == ',') { + if (!hasSeparator) { + hasSeparator = true + sb.append(c) + } + } else { + if (hasSeparator) { + if (decimalCount >= maxDecimals) continue + decimalCount++ + } + sb.append(c) + } + } + + if (sb.isNotEmpty() && (sb[0] == '.' || sb[0] == ',')) { + sb.insert(0, '0') + } + + return sb.toString() + } + } +} + +@Composable +fun rememberSendAmountState(decimals: Int = Coins.DEFAULT_DECIMALS): SendAmountState { + val state = rememberSaveable(saver = SendAmountState.Saver) { SendAmountState() } + state.decimals = decimals + return state +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendCommentState.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendCommentState.kt new file mode 100644 index 000000000..60c8c5d94 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/utils/SendCommentState.kt @@ -0,0 +1,64 @@ +package com.tonapps.deposit.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +sealed interface CommentError { + data object LedgerAsciiOnly : CommentError +} + +@Stable +class SendCommentState internal constructor( + private var isLedger: Boolean = false, +) { + var text by mutableStateOf("") + private set + + var error: CommentError? by mutableStateOf(null) + private set + + val isError: Boolean + get() = error != null + + fun onTextChange(newText: String) { + text = newText + error = validate(newText) + } + + internal fun updateConstraints(isLedger: Boolean) { + this.isLedger = isLedger + error = validate(text) + } + + private fun validate(text: String): CommentError? { + if (isLedger && text.isNotEmpty() && !text.all { it.code in 32..126 }) { + return CommentError.LedgerAsciiOnly + } + + return null + } + + companion object { + val Saver: Saver = Saver( + save = { it.text }, + restore = { saved -> SendCommentState().also { it.onTextChange(saved) } }, + ) + } +} + +@Composable +fun rememberSendCommentState( + isLedger: Boolean, +): SendCommentState { + val state = rememberSaveable(saver = SendCommentState.Saver) { SendCommentState(isLedger) } + LaunchedEffect(isLedger) { + state.updateConstraints(isLedger) + } + return state +} diff --git a/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/withdrawRouter.kt b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/withdrawRouter.kt new file mode 100644 index 000000000..36be195b3 --- /dev/null +++ b/apps/wallet/features/ramp/src/main/kotlin/com/tonapps/deposit/withdrawRouter.kt @@ -0,0 +1,425 @@ +package com.tonapps.deposit + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events +import com.tonapps.bus.generated.Events.WithdrawFlow.WithdrawFlowFrom +import com.tonapps.bus.generated.Events.WithdrawFlow.WithdrawFlowSellAsset +import com.tonapps.core.navigation.LocalResultStore +import com.tonapps.core.navigation.rememberResultStore +import com.tonapps.deposit.data.AssetFilter +import com.tonapps.deposit.screens.assets.AssetsCryptoExtendedFeature +import com.tonapps.deposit.screens.assets.AssetsCryptoExtendedFeatureData +import com.tonapps.deposit.screens.assets.AssetsExtendedScreen +import com.tonapps.deposit.screens.confirm.ConfirmFeature +import com.tonapps.deposit.screens.confirm.SendConfirmScreen +import com.tonapps.deposit.screens.currency.SelectCurrencyFeature +import com.tonapps.deposit.screens.currency.SelectCurrencyScreen +import com.tonapps.deposit.screens.method.KEY_CURRENCY_SELECTION_RESULT +import com.tonapps.deposit.screens.method.PaymentMethodFeature +import com.tonapps.deposit.screens.method.PaymentMethodFeatureData +import com.tonapps.deposit.screens.method.PaymentMethodScreen +import com.tonapps.deposit.screens.method.PaymentMethodSectionFilter +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.screens.network.SelectNetworkData +import com.tonapps.deposit.screens.network.SelectNetworkFeature +import com.tonapps.deposit.screens.network.SelectNetworkScreen +import com.tonapps.deposit.screens.picker.TokenPickerFeature +import com.tonapps.deposit.screens.picker.TokenPickerScreen +import com.tonapps.deposit.screens.ramp.RampFeature +import com.tonapps.deposit.screens.ramp.RampScreen +import com.tonapps.deposit.screens.ramp.RampType +import com.tonapps.deposit.screens.ramp.amount.DepositAmountFeature +import com.tonapps.deposit.screens.ramp.amount.DepositAmountScreen +import com.tonapps.deposit.screens.ramp.amount.RampAmountData +import com.tonapps.scanner.ScannerScreen +import com.tonapps.deposit.screens.send.SendExchangeData +import com.tonapps.deposit.screens.send.SendFeature +import com.tonapps.deposit.screens.send.SendFeatureData +import com.tonapps.deposit.screens.send.SendModel +import com.tonapps.deposit.screens.send.SendParams +import com.tonapps.deposit.screens.send.SendScreen +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import ui.moon.MoonNav + +private const val KEY_TOKEN_SELECTION_RESULT = "withdraw_token_selection_result" +private const val KEY_SCANNER_RESULT = "withdraw_scanner_result" + +internal fun WalletCurrency.toSellAsset(): WithdrawFlowSellAsset { + return when (address) { + WalletCurrency.USDT_TON.address -> WithdrawFlowSellAsset.TonJettonUSDT + WalletCurrency.USDT_TRON.address -> WithdrawFlowSellAsset.TronTrc20USDT + else -> WithdrawFlowSellAsset.TonNativeTON + } +} + +@Serializable +sealed interface WithdrawRoutes : NavKey { + + @Serializable + data object Ramp : WithdrawRoutes + + @Serializable + data class SendAmount( + val currency: WalletCurrency? = null, + val address: String? = null, + val exchangeData: SendExchangeData? = null, + val from: Events.SendNative.SendNativeFrom? = null, + ) : WithdrawRoutes + + @Serializable + data object SendConfirm : WithdrawRoutes + + @Serializable + data class ExtendedCryptoList(val filter: AssetFilter = AssetFilter.All, val preferredCurrency: String? = null) : WithdrawRoutes + + @Serializable + data class WithdrawMethod( + val asset: RampAsset? = null, + val ft: String? = null, + val tn: String? = null, + val tt: String? = null, + val fn: String? = null, + val cm: String? = null, + val sectionFilter: PaymentMethodSectionFilter = PaymentMethodSectionFilter.All, + val preferredCurrency: String? = null, + ) : WithdrawRoutes + + @Serializable + data class SelectNetwork(val to: RampAsset, val stablecoinCode: String, val selectedSymbol: String? = null, val fiatCurrency: WalletCurrency? = null) : WithdrawRoutes + + @Serializable + data class SelectCurrency(val selectedCode: String? = null) : WithdrawRoutes + + @Serializable + data class TokenPicker(val selectedTokenAddress: String? = null) : WithdrawRoutes + + @Serializable + data object Scanner : WithdrawRoutes + + @Serializable + data class Amount( + val asset: RampAsset, + val paymentMethodType: String, + val fiatCurrency: WalletCurrency + ) : WithdrawRoutes + +} + +@Composable +fun WithdrawRouter( + initial: WithdrawRoutes, + onBack: () -> Unit, + onSendSuccess: () -> Unit = onBack, + openProvider: (String) -> Unit, + onShowError: (String) -> Unit, + onAddressBook: ((onResult: (String) -> Unit) -> Unit)? = null, + onBuyTon: () -> Unit = {}, + onGetTrx: () -> Unit = {}, + onRechargeBattery: () -> Unit = {}, +) { + val backStack = rememberNavBackStack(initial) + val resultStore = rememberResultStore() + var sendParams by remember { mutableStateOf(null) } // TODO remove + val popBackStack = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + onBack() + } + } + + CompositionLocalProvider(LocalResultStore provides resultStore) { + MoonNav( + backStack = backStack, + ) { key -> + when (key) { + is WithdrawRoutes.Ramp -> NavEntry(key) { + val viewModel = koinViewModel { parametersOf(RampType.RampOff) } + RampScreen( + feature = viewModel, + rampType = RampType.RampOff, + onClose = onBack, + onQr = { }, + onSend = { + AnalyticsHelper.Default.events.withdrawFlow.withdrawClickSendTokens(from = WithdrawFlowFrom.WalletScreen) + backStack.add(WithdrawRoutes.SendAmount(null)) + }, + onBuyCash = { preferredCurrency -> + backStack.add(WithdrawRoutes.ExtendedCryptoList(filter = AssetFilter.Cash, preferredCurrency = preferredCurrency)) + }, + onBuyCrypto = { _ -> }, + onBuyStablecoins = { + backStack.add(WithdrawRoutes.ExtendedCryptoList(filter = AssetFilter.StablecoinRoot)) + }, + ) + } + + is WithdrawRoutes.SendAmount -> NavEntry(key) { + val feature = koinViewModel { + parametersOf(SendFeatureData(key.currency, key.address, key.exchangeData)) + } + + LaunchedEffect(Unit) { + val result = resultStore.removeResult(KEY_TOKEN_SELECTION_RESULT) + if (result != null) { + feature.sendAction(SendModel.Action.SelectToken(result)) + } + } + + SendScreen( + feature = feature, + scannerResult = resultStore.removeResult(KEY_SCANNER_RESULT), + onClose = onBack, + onBack = { popBackStack() }, + onAddressBook = onAddressBook, + onNavigateToConfirm = { params -> + sendParams = params + backStack.add(WithdrawRoutes.SendConfirm) + }, + onNavigateToTokenPicker = { selectedAddress -> + backStack.add(WithdrawRoutes.TokenPicker(selectedTokenAddress = selectedAddress)) + }, + onNavigateToScanner = { + backStack.add(WithdrawRoutes.Scanner) + }, + onShowError = onShowError + ) + } + + is WithdrawRoutes.SendConfirm -> NavEntry(key) { + val params = sendParams ?: run { + popBackStack() + return@NavEntry + } + + val feature = koinViewModel { parametersOf(params) } + + SendConfirmScreen( + feature = feature, + onClose = onBack, + onSendSuccess = onSendSuccess, + onBack = { popBackStack() }, + onBuyTon = onBuyTon, + onGetTrx = onGetTrx, + onRechargeBattery = onRechargeBattery, + ) + } + + is WithdrawRoutes.ExtendedCryptoList -> NavEntry(key) { + val viewModel = koinViewModel { parametersOf(AssetsCryptoExtendedFeatureData(RampType.RampOff, key.filter)) } + + val sectionFilter = when (key.filter) { + AssetFilter.Cash -> PaymentMethodSectionFilter.CashOnly + AssetFilter.Stablecoin, AssetFilter.StablecoinRoot -> PaymentMethodSectionFilter.StablecoinOnly + AssetFilter.All -> PaymentMethodSectionFilter.All + } + + AssetsExtendedScreen( + feature = viewModel, + title = "Asset to withdraw", + onClose = onBack, + onBack = { popBackStack() }, + onSelected = { currency -> + when (key.filter) { + AssetFilter.StablecoinRoot -> { + AnalyticsHelper.Default.events.withdrawFlow.withdrawClickSell( + from = WithdrawFlowFrom.WalletScreen, + sellAsset = currency.toSellAsset() + ) + + backStack.add(WithdrawRoutes.WithdrawMethod( + asset = RampAsset.Currency(currency), + sectionFilter = PaymentMethodSectionFilter.StablecoinOnly, + )) + } + AssetFilter.Stablecoin -> { + val rootAsset = RampAsset.Currency(currency) + backStack.add(WithdrawRoutes.SelectNetwork( + to = rootAsset, + stablecoinCode = rootAsset.currencyCode, + selectedSymbol = currency.code, + )) + } + else -> { + AnalyticsHelper.Default.events.withdrawFlow.withdrawClickSell( + from = WithdrawFlowFrom.WalletScreen, + sellAsset = currency.toSellAsset() + ) + + backStack.add(WithdrawRoutes.WithdrawMethod( + asset = RampAsset.Currency(currency), + sectionFilter = sectionFilter, + preferredCurrency = key.preferredCurrency, + )) + } + } + }, + ) + } + + is WithdrawRoutes.WithdrawMethod -> NavEntry(key) { + val viewModel = koinViewModel { + parametersOf(PaymentMethodFeatureData(key.asset, RampType.RampOff, key.ft, key.tn, key.tt, key.fn, key.cm, key.sectionFilter, key.preferredCurrency)) + } + val currencyResult = resultStore.removeResult(KEY_CURRENCY_SELECTION_RESULT) + + PaymentMethodScreen( + feature = viewModel, + fallbackAsset = key.asset, + currencySelectionResult = currencyResult, + onClose = onBack, + onBack = { popBackStack() }, + onPaymentMethodClick = { asset, paymentMethodType, currency -> + backStack.add( + WithdrawRoutes.Amount( + asset = asset, + paymentMethodType = paymentMethodType, + fiatCurrency = currency + ) + ) + }, + onBuyWithCrypto = { asset, destinationCurrency -> + val fromCurrency = (asset as? RampAsset.Currency)?.currency + backStack.add(WithdrawRoutes.SendAmount( + currency = fromCurrency, + exchangeData = SendExchangeData(exchangeTo = destinationCurrency), + )) + }, + onSelectCurrency = { selectedCode -> + backStack.add(WithdrawRoutes.SelectCurrency(selectedCode = selectedCode)) + }, + onSelectNetwork = { asset, selectedSymbol, fiatCurrency -> + backStack.add(WithdrawRoutes.SelectNetwork( + to = asset, + stablecoinCode = asset.currencyCode, + selectedSymbol = selectedSymbol, + fiatCurrency = fiatCurrency, + )) + }, + onOpenP2P = openProvider, + onAutoNavigateCrypto = { resolvedAsset, targetCurrency -> + val fromCurrency = (resolvedAsset as? RampAsset.Currency)?.currency + backStack.add(WithdrawRoutes.SendAmount( + currency = fromCurrency, + exchangeData = SendExchangeData(exchangeTo = targetCurrency), + )) + }, + onAutoNavigateCash = { resolvedAsset, paymentMethodType, fiatCurrency -> + if (paymentMethodType == "p2p") { // TODO handle + return@PaymentMethodScreen + } + + backStack.add( + WithdrawRoutes.Amount( + asset = resolvedAsset, + paymentMethodType = paymentMethodType, + fiatCurrency = fiatCurrency, + ) + ) + }, + ) + } + + is WithdrawRoutes.SelectNetwork -> NavEntry(key) { + val feature = koinViewModel { + parametersOf(SelectNetworkData( + stablecoinCode = key.stablecoinCode, + stablecoinNetwork = key.to.network, + rampType = RampType.RampOff, + selectedSymbol = key.selectedSymbol, + )) + } + val fromCurrency = (key.to as? RampAsset.Currency)?.currency + SelectNetworkScreen( + feature = feature, + showFee = true, + onSelect = { networkInfo -> + backStack.add(WithdrawRoutes.SendAmount( + currency = fromCurrency, + exchangeData = SendExchangeData( + exchangeTo = networkInfo.currency, + withdrawalFee = networkInfo.fee, + fiatCurrency = key.fiatCurrency, + minAmount = networkInfo.providerMinAmount, + maxAmount = networkInfo.providerMaxAmount, + ), + )) + }, + onClose = onBack, + onBack = { popBackStack() }, + ) + } + + is WithdrawRoutes.SelectCurrency -> NavEntry(key) { + val feature = koinViewModel() + SelectCurrencyScreen( + feature = feature, + selectedCurrencyCode = key.selectedCode, + onConfirm = { currency -> + resultStore.setResult(KEY_CURRENCY_SELECTION_RESULT, currency) + }, + onBack = { popBackStack() }, + onClose = onBack, + ) + } + + is WithdrawRoutes.TokenPicker -> NavEntry(key) { + val feature = koinViewModel() + TokenPickerScreen( + feature = feature, + selectedTokenAddress = key.selectedTokenAddress, + onTokenSelected = { token -> + resultStore.setResult(KEY_TOKEN_SELECTION_RESULT, token.token) + }, + onBack = { popBackStack() }, + onClose = onBack, + ) + } + + is WithdrawRoutes.Scanner -> NavEntry(key) { + ScannerScreen( + onResult = { value -> + resultStore.setResult(KEY_SCANNER_RESULT, value) + backStack.removeLastOrNull() + }, + onClose = { backStack.removeLastOrNull() }, + ) + } + + is WithdrawRoutes.Amount -> NavEntry(key) { + val amountData = RampAmountData( + assetFrom = key.asset, + assetTo = RampAsset.Currency(key.fiatCurrency), + paymentMethodType = key.paymentMethodType, + ) + val viewModel = koinViewModel { + parametersOf(amountData) + } + + DepositAmountScreen( + feature = viewModel, + onClose = onBack, + onBack = { popBackStack() }, + onContinue = openProvider, + ) + } + + else -> throw IllegalStateException("Unknown key: $key") + } + } + } +} diff --git a/apps/wallet/features/settings/build.gradle.kts b/apps/wallet/features/settings/build.gradle.kts new file mode 100644 index 000000000..2942bd269 --- /dev/null +++ b/apps/wallet/features/settings/build.gradle.kts @@ -0,0 +1,35 @@ +plugins { + id("target.android.compose") + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.compose.debugTooling) + + implementation(libs.bundles.nav3) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.core) + implementation(libs.koin.compose) + + implementation(projects.apps.wallet.localization) + + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.features) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.features.core) + + implementation(projects.kmp.core) + implementation(projects.kmp.ui) + implementation(projects.kmp.mvi) + implementation(projects.kmp.async) + + implementation(projects.ui.uikit.icon) + implementation(projects.ui.uikit.core) + + implementation(projects.lib.features) + implementation(projects.lib.icu) + implementation(projects.lib.extensions) + implementation(projects.lib.blockchain) +} diff --git a/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/DevSettingsFragment.kt b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/DevSettingsFragment.kt new file mode 100644 index 000000000..5d34f4899 --- /dev/null +++ b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/DevSettingsFragment.kt @@ -0,0 +1,32 @@ +package com.tonapps.settings.dev + +import android.os.Bundle +import android.view.View +import com.tonapps.core.ComposableFragment +import uikit.base.BaseFragment + +class DevSettingsFragment : ComposableFragment(), BaseFragment.BottomSheet { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val startRoute = requireArguments().getString(ARG_START_ROUTE) + + setContent { + DevSettingsRouter( + startRoute = startRoute, + onClose = { finish() }, + ) + } + } + + companion object { + private const val ARG_START_ROUTE = "start_route" + + fun newInstance(startRoute: String): DevSettingsFragment { + val fragment = DevSettingsFragment() + fragment.putStringArg(ARG_START_ROUTE, startRoute) + return fragment + } + } +} diff --git a/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/DevSettingsRouter.kt b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/DevSettingsRouter.kt new file mode 100644 index 000000000..f144fcdab --- /dev/null +++ b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/DevSettingsRouter.kt @@ -0,0 +1,54 @@ +package com.tonapps.settings.dev + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import com.tonapps.settings.dev.features.FeatureFlagsScreen +import com.tonapps.settings.dev.tooltips.TooltipsScreen +import kotlinx.serialization.Serializable +import ui.moon.MoonNav + +@Serializable +sealed interface DevSettingsRoutes : NavKey { + @Serializable + data object FeatureFlags : DevSettingsRoutes + + @Serializable + data object Tooltips : DevSettingsRoutes +} + +@Composable +fun DevSettingsRouter( + startRoute: String?, + onClose: () -> Unit, +) { + val initialRoute = when (startRoute) { + ROUTE_TOOLTIPS -> DevSettingsRoutes.Tooltips + else -> DevSettingsRoutes.FeatureFlags + } + val backStack = rememberNavBackStack(initialRoute) + + MoonNav( + backStack = backStack, + ) { key -> + when (key) { + is DevSettingsRoutes.FeatureFlags -> NavEntry(key) { + FeatureFlagsScreen( + onBack = onClose, + ) + } + + is DevSettingsRoutes.Tooltips -> NavEntry(key) { + TooltipsScreen( + onBack = onClose, + ) + } + + else -> throw IllegalStateException("Unknown key: $key") + } + } +} + +const val ROUTE_FEATURE_FLAGS = "feature_flags" +const val ROUTE_TOOLTIPS = "tooltips" diff --git a/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/features/FeatureFlagsScreen.kt b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/features/FeatureFlagsScreen.kt new file mode 100644 index 000000000..159bb5918 --- /dev/null +++ b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/features/FeatureFlagsScreen.kt @@ -0,0 +1,72 @@ +package com.tonapps.settings.dev.features + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Switch +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.tonapps.core.flags.FeatureManager +import com.tonapps.core.flags.WalletFeatureKey +import com.tonapps.core.helper.T +import ui.components.moon.MoonSmallItemTitle +import ui.components.moon.cell.TextCell +import ui.components.moon.container.MoonScaffold + +@Composable +fun FeatureFlagsScreen( + onBack: () -> Unit, +) { + MoonScaffold( + title = "Features", + onBack = onBack, + content = { + Column { + val keys = remember { WalletFeatureKey.entries } + for (i in keys) { + var isEnabled by remember { mutableStateOf(FeatureManager.isEnabled(i)) } + val isOverridden = remember(isEnabled) { FeatureManager.isOverridden(i) } + val value = FeatureManager.getValue(i, "") + val setFeature = { newValue: Boolean -> + isEnabled = newValue + FeatureManager.setFeatureEnabled(i, isEnabled) + T.show("Feature is ${if (isEnabled) "enabled" else "disabled"}! Please reload app for correct behaviour!") + } + + TextCell( + title = i.featureKey, + subtitle = value, + description = { + if (isOverridden) { + MoonSmallItemTitle( + text = "Overridden: ${ + FeatureManager.getRemoteValue( + i + ) + }" + ) + } + }, + content = { + Switch( + checked = isEnabled, + onCheckedChange = { + setFeature(!isEnabled) + }, + ) + }, + onClick = { + setFeature(!isEnabled) + }, + onLongClick = { + FeatureManager.reset(i) + isEnabled = FeatureManager.isEnabled(i) + T.show("Feature $i is sync with remote!") + } + ) + } + } + }, + ) +} diff --git a/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/tooltips/TooltipsScreen.kt b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/tooltips/TooltipsScreen.kt new file mode 100644 index 000000000..b15c8a129 --- /dev/null +++ b/apps/wallet/features/settings/src/main/kotlin/com/tonapps/settings/dev/tooltips/TooltipsScreen.kt @@ -0,0 +1,73 @@ +package com.tonapps.settings.dev.tooltips + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.DropdownMenu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.tonapps.core.flags.TooltipManager +import com.tonapps.core.flags.TooltipState +import com.tonapps.core.flags.WalletTooltipKey +import ui.components.moon.MoonItemTitle +import ui.components.moon.cell.TextCell +import ui.components.moon.container.MoonScaffold +import ui.theme.UIKit + +@Composable +fun TooltipsScreen( + onBack: () -> Unit, +) { + MoonScaffold( + title = "Tooltips", + onBack = onBack, + content = { + Column { + val keys = remember { WalletTooltipKey.entries } + for (i in keys) { + var state by remember { mutableStateOf(TooltipManager.getState(i)) } + var expanded by remember { mutableStateOf(false) } + + TextCell( + title = i.tooltipName, + content = { + Box { + MoonItemTitle( + text = state.name, + color = UIKit.colorScheme.text.secondary, + ) + DropdownMenu( + containerColor = UIKit.colorScheme.background.contentTint, + shape = UIKit.shapes.large, + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + TooltipState.entries.forEach { option -> + TextCell( + title = option.name, + titleColor = if (option == state) { + UIKit.colorScheme.text.accent + } else { + UIKit.colorScheme.text.primary + }, + onClick = { + state = option + TooltipManager.setState(i, option) + expanded = false + }, + ) + } + } + } + }, + onClick = { + expanded = true + }, + ) + } + } + }, + ) +} diff --git a/apps/wallet/features/trading/build.gradle.kts b/apps/wallet/features/trading/build.gradle.kts new file mode 100644 index 000000000..6bc9bf7bf --- /dev/null +++ b/apps/wallet/features/trading/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("target.android.compose") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + debugImplementation(libs.compose.debugTooling) + + implementation(libs.bundles.nav3) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.core) + implementation(libs.koin.compose) + implementation(libs.kotlinx.serialization.json) + + implementation(projects.apps.wallet.api) + implementation(projects.tonapi.trading) + implementation(projects.tonapi.tonkeeper) + implementation(projects.apps.wallet.localization) + + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.tokens) + implementation(projects.apps.wallet.data.events) + implementation(projects.apps.wallet.data.tx) + implementation(projects.apps.wallet.data.staking) + implementation(projects.apps.wallet.features.events) + + implementation(projects.apps.wallet.features.core) + implementation(projects.kmp.core) + implementation(projects.kmp.ui) + implementation(projects.kmp.mvi) + implementation(projects.kmp.async) + + implementation(projects.ui.uikit.icon) + implementation(projects.ui.uikit.core) + + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.icu) + implementation(projects.lib.bus) +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/AssetsFragment.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/AssetsFragment.kt new file mode 100644 index 000000000..5c6efaabc --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/AssetsFragment.kt @@ -0,0 +1,112 @@ +package com.tonapps.trading + +import android.os.Bundle +import android.view.View +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.bus.generated.Events.AssetScreen.AssetScreenFrom +import com.tonapps.wallet.data.events.tx.model.TxEvent +import com.tonapps.core.ComposableFragment +import io.tradingapi.models.AssetsTab +import io.tradingapi.models.MarketItem +import uikit.extensions.activity + +class AssetsFragment : ComposableFragment() { + + interface Delegate { + fun onOpenToken(token: TokenEntity, eventsOnly: Boolean = false) + fun onOpenUrl(url: String) + fun onOpenSwap(fromToken: String, toToken: String) + fun onOpenSend(tokenAddress: String) + fun onOpenReceive(token: TokenEntity?) + fun onOpenTxDetails(tx: TxEvent, actionIndex: Int) + fun onOpenStaking() + fun onOpenUnverifiedInfo() + } + + private val initial: AssetsRoutes + get() { + val args = arguments + val assetId = args?.getString(ARG_ASSET_ID) + return if (assetId != null) { + AssetsRoutes.AssetDetails( + assetId = assetId, + previewName = args.getString(ARG_PREVIEW_NAME).orEmpty(), + previewImageUrl = args.getString(ARG_PREVIEW_IMAGE_URL).orEmpty(), + from = args.getString(ARG_FROM) + ?.let { value -> AssetScreenFrom.entries.firstOrNull { it.key == value } } + ?: AssetScreenFrom.TradeScreen, + ) + } else { + AssetsRoutes.Assets( + focusSearch = args?.getBoolean(ARG_FOCUS_SEARCH) ?: false, + initialTab = args?.getString(ARG_INITIAL_TAB)?.let { tabValue -> + AssetsTab.entries.firstOrNull { it.value == tabValue } + } ?: AssetsTab.all, + ) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val delegate = context?.activity as? Delegate + + setContent { + AssetsRouter( + initial = initial, + onOpenToken = { token, eventsOnly -> delegate?.onOpenToken(token, eventsOnly) }, + onOpenUrl = { delegate?.onOpenUrl(it) }, + onOpenSwap = { from, to -> delegate?.onOpenSwap(from, to) }, + onOpenSend = { delegate?.onOpenSend(it) }, + onOpenReceive = { delegate?.onOpenReceive(it) }, + onOpenTxDetails = { tx, index -> delegate?.onOpenTxDetails(tx, index) }, + onOpenStaking = { delegate?.onOpenStaking() }, + onOpenUnverifiedInfo = { delegate?.onOpenUnverifiedInfo() }, + onClose = { finish() }, + ) + } + } + + companion object { + private const val ARG_ASSET_ID = "asset_id" + private const val ARG_PREVIEW_NAME = "preview_name" + private const val ARG_PREVIEW_IMAGE_URL = "preview_image_url" + private const val ARG_FOCUS_SEARCH = "focus_search" + private const val ARG_INITIAL_TAB = "initial_tab" + private const val ARG_FROM = "from" + + fun newInstance( + focusSearch: Boolean = false, + initialTab: AssetsTab = AssetsTab.all, + ) = AssetsFragment().apply { + arguments = Bundle().apply { + putBoolean(ARG_FOCUS_SEARCH, focusSearch) + putString(ARG_INITIAL_TAB, initialTab.value) + } + } + + fun newInstance( + assetId: String, + from: AssetScreenFrom, + name: String = "", + imageUrl: String = "", + ) = AssetsFragment().apply { + arguments = Bundle().apply { + putString(ARG_ASSET_ID, assetId) + putString(ARG_PREVIEW_NAME, name) + putString(ARG_PREVIEW_IMAGE_URL, imageUrl) + putString(ARG_FROM, from.key) + } + } + + fun newInstance( + marketItem: MarketItem, + from: AssetScreenFrom, + ) = newInstance( + assetId = marketItem.asset.id, + name = marketItem.asset.name, + imageUrl = marketItem.asset.imageUrl, + from = from, + ) + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/Extensions.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/Extensions.kt new file mode 100644 index 000000000..e295e530e --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/Extensions.kt @@ -0,0 +1,164 @@ +package com.tonapps.trading + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.core.extensions.externalDrawableUrl +import com.tonapps.extensions.toUriOrNull +import com.tonapps.uikit.icon.UIKitIcon +import io.tradingapi.models.AssetRef +import io.tradingapi.models.AssetType +import io.tradingapi.models.LinkType +import ui.theme.UIKit +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +fun Long.formatChartTime( + locale: Locale, + includeTime: Boolean, + includeYear: Boolean, +): String { + if (this <= 0L) return "" + + val zdt = Instant.ofEpochSecond(this) + .atZone(ZoneId.systemDefault()) + + val pattern = buildString { + if (includeYear) append("yyyy ") + append("EEE, d MMM") + if (includeTime) append(" HH:mm") + } + + return DateTimeFormatter.ofPattern(pattern, locale) + .format(zdt) +} + +@Composable +fun String.percentDiffColor(): Color { + return when { + startsWith("-") -> UIKit.colorScheme.accent.red + startsWith("+") -> UIKit.colorScheme.accent.green + else -> UIKit.colorScheme.text.secondary + } +} + +val AssetRef.asTokenEntity: TokenEntity + get() { + val chain = id.substringBefore("/") + val addr = id.substringAfterLast("/").let { + if (it == "coin") WalletCurrency.TON_CHAIN_KEY else it + } + return when (chain.lowercase()) { + "ton" -> { + if (addr.equals(WalletCurrency.TON_CHAIN_KEY, ignoreCase = true)) { + TokenEntity.TON + } else { + TokenEntity( + blockchain = Blockchain.TON, + address = addr, + name = name, + symbol = symbol, + imageUri = imageUrl.toUriOrNull() ?: TokenEntity.TON_ICON_URI, + decimals = decimals, + verification = TokenEntity.Verification.none, + isRequestMinting = false, + isTransferable = true, + customPayloadApiUri = null, + ) + } + } + "tron" -> TokenEntity( + blockchain = Blockchain.TRON, + address = addr, + name = name, + symbol = symbol, + imageUri = imageUrl.toUriOrNull() ?: TokenEntity.TRX_ICON_URI, + decimals = decimals, + verification = TokenEntity.Verification.none, + isRequestMinting = false, + isTransferable = true, + customPayloadApiUri = null, + ) + else -> TokenEntity( + blockchain = Blockchain.TON, + address = addr, + name = name, + symbol = symbol, + imageUri = imageUrl.toUriOrNull() ?: TokenEntity.TON_ICON_URI, + decimals = decimals, + verification = TokenEntity.Verification.none, + isRequestMinting = false, + isTransferable = true, + customPayloadApiUri = null, + ) + } + } + +fun AssetRef.isTokenized(): Boolean = + assetType == AssetType.stocks || assetType == AssetType.etfs + +fun AssetRef.omnistonBuyTokens(): Pair { + val target = asTokenEntity.asCurrency + return if (target == WalletCurrency.TON) { + WalletCurrency.USDT_TON.address to WalletCurrency.TON.address + } else { + val fromAddress = if (isTokenized()) { + WalletCurrency.USDT_TON.address + } else { + WalletCurrency.TON.address + } + fromAddress to target.address + } +} + +fun AssetRef.omnistonSellTokens(): Pair { + val target = asTokenEntity.asCurrency + return if (target == WalletCurrency.TON) { + WalletCurrency.TON.address to WalletCurrency.USDT_TON.address + } else { + val toAddress = if (isTokenized()) { + WalletCurrency.USDT_TON.address + } else { + WalletCurrency.TON.address + } + target.address to toAddress + } +} + +fun LinkType.iconRes(): Int = when (this) { + LinkType.telegram -> UIKitIcon.ic_telegram_16 + LinkType.twitter -> UIKitIcon.ic_x_16 + LinkType.facebook -> UIKitIcon.ic_facebook_16 + LinkType.instagram -> UIKitIcon.ic_instagram_16 + LinkType.discord -> UIKitIcon.ic_discord_16 + LinkType.github -> UIKitIcon.ic_github_16 + LinkType.getgems -> UIKitIcon.ic_getgems_16 + else -> UIKitIcon.ic_globe_16 +} + +//TODO: remove in multichain +@Composable +fun String.assetChainImageUrl(): String? { + val context = LocalContext.current + + if (contains("coin")) { + return null + } + + val address = substringAfterLast("/") + val isUsdtTon = startsWith("ton") && + address.equals(WalletCurrency.USDT_TON_ADDRESS, ignoreCase = true) + val isUsdtTron = startsWith("tron") && + address == WalletCurrency.USDT_TRON.address + + return when { + isUsdtTon -> context.externalDrawableUrl(UIKitIcon.ic_ton) + isUsdtTron -> context.externalDrawableUrl(UIKitIcon.ic_tron) + else -> null + } +} \ No newline at end of file diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/Module.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/Module.kt new file mode 100644 index 000000000..567b718c3 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/Module.kt @@ -0,0 +1,9 @@ +package com.tonapps.trading + +import com.tonapps.trading.data.TradingRepository +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val tradingModule = module { + singleOf(::TradingRepository) +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/TradeEntryTracker.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/TradeEntryTracker.kt new file mode 100644 index 000000000..5c44d7869 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/TradeEntryTracker.kt @@ -0,0 +1,21 @@ +package com.tonapps.trading + +import com.tonapps.bus.generated.Events.TradeUiFlow.TradeUiFlowFrom +import java.util.concurrent.atomic.AtomicBoolean + +object TradeEntryTracker { + + private val fromDeepLink = AtomicBoolean(false) + + fun markDeepLink() { + fromDeepLink.set(true) + } + + fun consumeFrom(): TradeUiFlowFrom { + return if (fromDeepLink.get()) { + TradeUiFlowFrom.DeepLink + } else { + TradeUiFlowFrom.TabBar + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/assetsRouter.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/assetsRouter.kt new file mode 100644 index 000000000..cfdffa23a --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/assetsRouter.kt @@ -0,0 +1,104 @@ +package com.tonapps.trading + +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.bus.generated.Events.AssetScreen.AssetScreenFrom +import com.tonapps.wallet.data.events.tx.model.TxEvent +import com.tonapps.trading.screens.assets.AssetsFeature +import com.tonapps.trading.screens.assets.AssetsScreen +import com.tonapps.trading.screens.details.AssetDetailsFeature +import com.tonapps.trading.screens.details.AssetDetailsScreen +import io.tradingapi.models.AssetsTab +import kotlinx.serialization.Serializable +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import ui.moon.MoonNav + +@Serializable +sealed interface AssetsRoutes : NavKey { + + @Serializable + data class Assets( + val focusSearch: Boolean = false, + val initialTab: AssetsTab = AssetsTab.all, + ) : AssetsRoutes + + @Serializable + data class AssetDetails( + val assetId: String, + val previewName: String, + val previewImageUrl: String, + val from: AssetScreenFrom, + ) : AssetsRoutes +} + +@Composable +fun AssetsRouter( + initial: AssetsRoutes = AssetsRoutes.Assets(), + onOpenToken: (token: TokenEntity, eventsOnly: Boolean) -> Unit, + onOpenUrl: (String) -> Unit, + onOpenSwap: (fromToken: String, toToken: String) -> Unit, + onOpenSend: (tokenAddress: String) -> Unit, + onOpenReceive: (token: TokenEntity?) -> Unit, + onOpenTxDetails: (tx: TxEvent, actionIndex: Int) -> Unit, + onOpenStaking: () -> Unit, + onOpenUnverifiedInfo: () -> Unit, + onClose: () -> Unit, +) { + val backStack = rememberNavBackStack(initial) + + val onBack: () -> Unit = { + if (backStack.size > 1) { + backStack.removeLastOrNull() + } else { + onClose() + } + } + + MoonNav(backStack = backStack) { key -> + when (key) { + is AssetsRoutes.Assets -> NavEntry(key) { + val viewModel = koinViewModel { parametersOf(key.initialTab) } + AssetsScreen( + feature = viewModel, + focusSearch = key.focusSearch, + initialTab = key.initialTab, + onOpenAssetDetails = { asset -> + backStack.add(AssetsRoutes.AssetDetails( + assetId = asset.id, + previewName = asset.name, + previewImageUrl = asset.imageUrl, + from = AssetScreenFrom.TradeScreen, + )) + }, + onBack = onBack, + ) + } + + is AssetsRoutes.AssetDetails -> NavEntry(key) { + val viewModel = koinViewModel { + parametersOf(key.assetId, key.from) + } + AssetDetailsScreen( + feature = viewModel, + previewName = key.previewName, + previewImageUrl = key.previewImageUrl, + onOpenToken = onOpenToken, + onOpenUrl = onOpenUrl, + onOpenSwap = onOpenSwap, + onOpenSend = onOpenSend, + onOpenReceive = onOpenReceive, + onOpenTxDetails = onOpenTxDetails, + onOpenStaking = onOpenStaking, + onOpenUnverifiedInfo = onOpenUnverifiedInfo, + onBack = onBack, + ) + } + + else -> throw IllegalStateException("Unknown key: $key") + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/data/TradingRepository.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/data/TradingRepository.kt new file mode 100644 index 000000000..c83b5f7be --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/data/TradingRepository.kt @@ -0,0 +1,92 @@ +package com.tonapps.trading.data + +import com.tonapps.async.Async +import com.tonapps.core.helper.EnvironmentHelper +import com.tonapps.extensions.CacheKey +import com.tonapps.extensions.TimedCacheMemory +import com.tonapps.wallet.api.API +import uikit.chart.ChartPeriod +import uikit.chart.ChartPoint +import io.tradingapi.models.AssetDetailsResponse +import io.tradingapi.models.AssetsCatalogResponse +import io.tradingapi.models.AssetsTab +import io.tradingapi.models.ShelfGroup +import kotlinx.coroutines.withContext + +class TradingRepository( + private val api: API, + private val environment: EnvironmentHelper, +) { + sealed interface Keys : CacheKey { + data object Shelves : Keys + } + + private val cache = TimedCacheMemory() + + suspend fun getShelfGroups(): List { + return cache.getOrLoad(Keys.Shelves) { + withContext(Async.Io) { + api.trading.shelves.getShelvesConfig( + xLang = environment.locale(), + storeCountryCode = environment.storeCountry(), + deviceCountryCode = environment.deviceCountry(), + simCountry = environment.simCountry(), + timezone = environment.timezone(), + isVpnActive = environment.isVpnActive(), + currency = environment.currency(), + ).groups.filter { it.items.isNotEmpty() } + } + } + } + + suspend fun getAssets( + query: String, + tab: AssetsTab, + cursor: String? + ): AssetsCatalogResponse { + return withContext(Async.Io) { + api.trading.assets.getAssetsCatalog( + q = query.ifBlank { null }, + tab = tab, + cursor = cursor, + xLang = environment.locale(), + storeCountryCode = environment.storeCountry(), + deviceCountryCode = environment.deviceCountry(), + simCountry = environment.simCountry(), + timezone = environment.timezone(), + isVpnActive = environment.isVpnActive(), + currency = environment.currency(), + ) + } + } + + suspend fun getAssetDetails(assetId: String): AssetDetailsResponse { + return withContext(Async.Io) { + api.trading.assets.getAssetDetails( + assetId = assetId, + xLang = environment.locale(), + storeCountryCode = environment.storeCountry(), + deviceCountryCode = environment.deviceCountry(), + simCountry = environment.simCountry(), + timezone = environment.timezone(), + isVpnActive = environment.isVpnActive(), + currency = environment.currency(), + ) + } + } + + suspend fun getAssetChart(chartToken: String, period: ChartPeriod): List { + return withContext(Async.Io) { + val endDate = System.currentTimeMillis() / 1000 + val startDate = endDate - period.durationSeconds + api.loadChart(chartToken, environment.currency(), startDate, endDate) + .map { ChartPoint(it.date, it.price) } + } + } + + suspend fun clearCache() { + cache.remove(Keys.Shelves) + } + + fun getCurrency(): String = environment.currency() +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetItem.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetItem.kt new file mode 100644 index 000000000..4136509df --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetItem.kt @@ -0,0 +1,30 @@ +package com.tonapps.trading.screens.assets + +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.icu.Formatter +import io.tradingapi.models.MarketItem +import java.math.BigDecimal + +data class AssetItem( + val id: String, + val symbol: String, + val name: String, + val imageUrl: String, + val formattedPrice: String, + val formattedChange: String, +) + +fun MarketItem.toAssetItem(currencyCode: String): AssetItem { + return AssetItem( + id = asset.id, + symbol = asset.symbol, + name = asset.name, + imageUrl = asset.imageUrl, + formattedPrice = CurrencyFormatter.formatFiat( + currency = currencyCode, + value = BigDecimal(metrics.price), + stripTrailingZeros = true, + ).toString(), + formattedChange = Formatter.percent(metrics.change24hPercent), + ) +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsFeature.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsFeature.kt new file mode 100644 index 000000000..8aca56316 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsFeature.kt @@ -0,0 +1,122 @@ +package com.tonapps.trading.screens.assets + +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.core.helper.EnvironmentHelper +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.trading.TradeEntryTracker +import com.tonapps.trading.data.TradingRepository +import io.tradingapi.models.AssetsTab + +sealed interface AssetsAction : MviAction { + data object Init : AssetsAction + data class Search(val query: String, val tab: AssetsTab) : AssetsAction + data object LoadMore : AssetsAction +} + +sealed interface AssetsState : MviState { + data object Loading : AssetsState + data object Error : AssetsState + data class Data( + val items: List, + val nextCursor: String?, + val isLoadingMore: Boolean, + val query: String, + val tab: AssetsTab, + ) : AssetsState { + val hasMore: Boolean get() = nextCursor != null + } +} + +class AssetsViewState( + val global: MviProperty, +) : MviViewState + +class AssetsFeature( + private val tradingRepository: TradingRepository, + private val environment: EnvironmentHelper, + val initialTab: AssetsTab = AssetsTab.all, +) : MviFeature( + initState = AssetsState.Loading, + initAction = AssetsAction.Init, +) { + + override fun createViewState(): AssetsViewState { + return buildViewState { + AssetsViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: AssetsAction) { + when (action) { + AssetsAction.Init -> load(query = "", tab = initialTab) + is AssetsAction.Search -> { + load(query = action.query, tab = action.tab) + } + + AssetsAction.LoadMore -> loadMore() + } + } + + private suspend fun load(query: String, tab: AssetsTab) { + setState { AssetsState.Loading } + try { + val currencyCode = environment.currency() + val response = tradingRepository.getAssets(query, tab, cursor = null) + setState { + AssetsState.Data( + items = response.items.map { it.toAssetItem(currencyCode) }, + nextCursor = response.nextCursor, + isLoadingMore = false, + query = query, + tab = tab, + ) + } + AnalyticsHelper.Default.events.tradeUiFlow.tradeSearch( + from = TradeEntryTracker.consumeFrom(), + query = query.ifBlank { null } + ) + } catch (e: Throwable) { + L.e(e) + setState { AssetsState.Error } + } + } + + private suspend fun loadMore() { + val current = obtainSpecificState() ?: return + if (current.nextCursor == null || current.isLoadingMore) return + setState { current.copy(isLoadingMore = true) } + try { + val currencyCode = environment.currency() + val response = + tradingRepository.getAssets(current.query, current.tab, current.nextCursor) + setState { + val data = this as? AssetsState.Data ?: return@setState this + data.copy( + items = data.items + response.items.map { it.toAssetItem(currencyCode) }, + nextCursor = response.nextCursor, + isLoadingMore = false, + ) + } + } catch (e: Throwable) { + L.e(e) + setState { + val data = this as? AssetsState.Data ?: return@setState this + data.copy(isLoadingMore = false) + } + } + } + + fun trackAssetClick(asset: AssetItem) { + val data = obtainSpecificState() ?: return + AnalyticsHelper.Default.events.tradeUiFlow.tradeSearchClick( + from = TradeEntryTracker.consumeFrom(), + asset = asset.id, + query = data.query.ifBlank { null } + ) + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsScreen.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsScreen.kt new file mode 100644 index 000000000..ecefe161a --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsScreen.kt @@ -0,0 +1,300 @@ +package com.tonapps.trading.screens.assets + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.wallet.localization.Localization +import io.tradingapi.models.AssetsTab +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import ui.components.moon.MoonChipBar +import ui.components.moon.MoonDivider +import ui.components.moon.MoonItem +import ui.components.moon.MoonItemDivider +import ui.components.moon.cell.MoonSearchCell +import ui.components.moon.cell.defaultBundleType +import ui.components.moon.container.MoonScaffold +import ui.components.moon.screen.MoonEmptyScreen +import ui.components.moon.screen.MoonEmptyScreenType +import ui.preview.ThemedPreview +import ui.theme.UIKit + +private const val LOAD_MORE_THRESHOLD = 5 +private const val SEARCH_DEBOUNCE_MS = 300L + +@Composable +fun AssetsScreen( + feature: AssetsFeature, + focusSearch: Boolean, + initialTab: AssetsTab, + onOpenAssetDetails: (AssetItem) -> Unit, + onBack: () -> Unit, +) { + val state by feature.state.global.observeSafeState() + + AssetsContent( + state = state, + focusSearch = focusSearch, + initialTab = initialTab, + onSearch = { query, tab -> feature.sendAction(AssetsAction.Search(query, tab)) }, + onLoadMore = { feature.sendAction(AssetsAction.LoadMore) }, + onAssetClick = { + feature.trackAssetClick(it) + onOpenAssetDetails(it) + }, + onBack = onBack, + ) +} + +@Composable +private fun AssetsContent( + state: AssetsState, + focusSearch: Boolean, + initialTab: AssetsTab, + onSearch: (String, AssetsTab) -> Unit, + onLoadMore: () -> Unit, + onAssetClick: (AssetItem) -> Unit, + onBack: () -> Unit, +) { + val searchText = rememberSaveable { mutableStateOf("") } + val selectedTab = rememberSaveable { mutableStateOf(initialTab) } + var searchInitialized by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + val isListScrolled by remember { + derivedStateOf { + listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 + } + } + + LaunchedEffect(searchText.value, selectedTab.value) { + if (!searchInitialized) { + searchInitialized = true + return@LaunchedEffect + } + delay(SEARCH_DEBOUNCE_MS) + onSearch(searchText.value.trim(), selectedTab.value) + } + + LaunchedEffect(selectedTab.value) { + listState.scrollToItem(0) + } + + val tabTitles = mapOf( + AssetsTab.all to stringResource(Localization.tab_all), + AssetsTab.tokens to stringResource(Localization.tab_tokens), + AssetsTab.stocks to stringResource(Localization.tab_stocks), + AssetsTab.etfs to stringResource(Localization.tab_etfs), + ) + val tabs = remember(tabTitles) { + AssetsTab.entries.map { tab -> + MoonItem(id = tab.ordinal, title = tabTitles.getValue(tab)) + }.toImmutableList() + } + + MoonScaffold( + modifier = Modifier + .nestedScroll(rememberNestedScrollInteropConnection()) + .statusBarsPadding(), + title = stringResource(Localization.crypto), + onBack = onBack, + ) { + MoonSearchCell( + searchText = searchText, + placeholder = stringResource(Localization.search_by_ticker), + onChanged = { searchText.value = it }, + isFocusOnStart = focusSearch, + ) + Spacer(Modifier.height(8.dp)) + MoonChipBar( + modifier = Modifier.padding(vertical = 8.dp), + filters = tabs, + selectedId = selectedTab.value.ordinal, + onSelect = { + selectedTab.value = AssetsTab.entries[it.id] + }, + ) + Spacer(Modifier.height(8.dp)) + if (isListScrolled) { + MoonDivider() + } + when (state) { + AssetsState.Loading -> AssetsShimmer() + AssetsState.Error -> MoonEmptyScreen( + type = MoonEmptyScreenType.Error, + text = stringResource(Localization.something_went_wrong), + description = stringResource(Localization.could_not_load_content), + buttonText = stringResource(Localization.retry), + onButtonClick = { onSearch(searchText.value.trim(), selectedTab.value) }, + ) + + is AssetsState.Data -> AssetsList( + query = remember(searchText.value) { searchText.value.trim() }, + items = state.items, + hasMore = state.hasMore, + isLoadingMore = state.isLoadingMore, + listState = listState, + onLoadMore = onLoadMore, + onAssetClick = onAssetClick, + ) + } + } +} + +@Composable +private fun AssetsList( + query: String, + items: List, + hasMore: Boolean, + isLoadingMore: Boolean, + listState: LazyListState, + onLoadMore: () -> Unit, + onAssetClick: (AssetItem) -> Unit, +) { + val shouldLoadMore by remember(hasMore) { + derivedStateOf { + if (!hasMore) return@derivedStateOf false + val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + val total = listState.layoutInfo.totalItemsCount + total > 0 && lastVisible >= total - LOAD_MORE_THRESHOLD + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) onLoadMore() + } + + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + keyboardController?.hide() + focusManager.clearFocus() + return Offset.Zero + } + } + } + + if (items.isEmpty()) { + MoonEmptyScreen( + text = stringResource(Localization.not_found), + description = stringResource(Localization.search_no_result, query) + ) + return + } + + val navBarPadding = WindowInsets.navigationBars.asPaddingValues() + + LazyColumn( + state = listState, + contentPadding = PaddingValues( + bottom = 16.dp + navBarPadding.calculateBottomPadding() + ), + modifier = Modifier.nestedScroll(nestedScrollConnection), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + if (index > 0) MoonItemDivider() + AssetCell( + item = item, + position = defaultBundleType(items.size, index), + onClick = { onAssetClick(item) }, + ) + } + + if (isLoadingMore) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = UIKit.colorScheme.accent.blue, + ) + } + } + } + } +} + +private fun mockAssetItem( + symbol: String, + name: String, + price: String, + change: String, +) = AssetItem( + id = "mock::$symbol", + symbol = symbol, + name = name, + imageUrl = "", + formattedPrice = price, + formattedChange = change, +) + +private val previewItems = listOf( + mockAssetItem("TON", "Toncoin", "$5.42", "+ 3.21 %"), + mockAssetItem("USDT", "Tether", "$1.00", "0.00 %"), + mockAssetItem("BTC", "Bitcoin", "$98 430", "- 1.54 %"), + mockAssetItem("ETH", "Ethereum", "$3 210", "+ 2.10 %"), + mockAssetItem("NOT", "Notcoin", "$0.0071", "- 0.87 %"), +) + +@Preview +@Composable +private fun AssetsScreenPreview() { + ThemedPreview(isDarkOnly = true) { + AssetsContent( + state = AssetsState.Data( + items = previewItems, + nextCursor = null, + isLoadingMore = false, + query = "", + tab = AssetsTab.all, + ), + focusSearch = false, + initialTab = AssetsTab.all, + onSearch = { _, _ -> }, + onLoadMore = {}, + onAssetClick = { _ -> }, + onBack = {}, + ) + } +} + diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsShimmer.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsShimmer.kt new file mode 100644 index 000000000..d40b5f483 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/AssetsShimmer.kt @@ -0,0 +1,134 @@ +package com.tonapps.trading.screens.assets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import ui.components.moon.MoonTextShimmer +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.defaultBundleType +import ui.theme.Dimens +import ui.theme.UIKit + +private const val SHIMMER_ROW_COUNT = 10 +private const val SHIMMER_PHASE = 0f + +private const val ASSET_SYMBOL_PLACEHOLDER = "TONCOIN" +private const val ASSET_PRICE_PLACEHOLDER = "$12,345.67" +private const val ASSET_NAME_PLACEHOLDER = "MicroStrategy xStock" +private const val ASSET_CHANGE_PLACEHOLDER = "+9.99 %" + +@Composable +internal fun AssetsShimmer( + modifier: Modifier = Modifier, +) { + val fill = UIKit.colorScheme.background.contentTint + val highlight = UIKit.colorScheme.background.highlighted + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(bottom = 16.dp), + ) { + repeat(SHIMMER_ROW_COUNT) { index -> + MoonBundleCell(position = defaultBundleType(SHIMMER_ROW_COUNT, index)) { + AssetRowShimmer( + fill = fill, + highlight = highlight, + ) + } + } + } +} + +@Composable +private fun AssetRowShimmer( + fill: Color, + highlight: Color, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 76.dp) + .padding(horizontal = Dimens.offsetMedium, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(fill), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Top, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MoonTextShimmer( + text = ASSET_SYMBOL_PLACEHOLDER, + style = UIKit.typography.label1, + phase = SHIMMER_PHASE, + cornerRadius = 12.dp, + backgroundFill = fill, + highlightColor = highlight, + ) + MoonTextShimmer( + text = ASSET_PRICE_PLACEHOLDER, + style = UIKit.typography.label1, + phase = SHIMMER_PHASE, + cornerRadius = 12.dp, + backgroundFill = fill, + highlightColor = highlight, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MoonTextShimmer( + text = ASSET_NAME_PLACEHOLDER, + style = UIKit.typography.body2, + phase = SHIMMER_PHASE, + cornerRadius = 8.dp, + backgroundFill = fill, + highlightColor = highlight, + ) + MoonTextShimmer( + text = ASSET_CHANGE_PLACEHOLDER, + style = UIKit.typography.body2, + phase = SHIMMER_PHASE, + cornerRadius = 8.dp, + backgroundFill = fill, + highlightColor = highlight, + ) + } + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/assetsComponents.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/assetsComponents.kt new file mode 100644 index 000000000..69725b8d8 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/assets/assetsComponents.kt @@ -0,0 +1,74 @@ +package com.tonapps.trading.screens.assets + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.tonapps.trading.percentDiffColor +import com.tonapps.uikit.icon.UIKitIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonItemSubtitle +import ui.components.moon.MoonItemTitle +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonBundlePosition +import ui.components.moon.cell.TextCell +import ui.painterResource +import ui.theme.UIKit + +@Composable +internal fun AssetCell( + item: AssetItem, + position: MoonBundlePosition = MoonBundlePosition.Default, + onClick: () -> Unit = {}, +) { + MoonBundleCell(position = position) { + TextCell( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MoonItemTitle(text = item.symbol) + MoonItemTitle(text = item.formattedPrice) + } + }, + subtitle = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MoonItemSubtitle(text = item.name) + MoonItemSubtitle( + text = item.formattedChange, + color = item.formattedChange.percentDiffColor(), + ) + } + }, + image = { + MoonItemImage( + modifier = Modifier + .background( + color = UIKit.colorScheme.background.contentTint, + shape = CircleShape + ) + .size(44.dp) + .clip(CircleShape), + image = item.imageUrl, + placeholder = painterResource(UIKitIcon.ic_illustration), + size = 44.dp + ) + }, + onClick = onClick, + minHeight = 76.dp, + ) + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsFeature.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsFeature.kt new file mode 100644 index 000000000..846aec250 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsFeature.kt @@ -0,0 +1,349 @@ +package com.tonapps.trading.screens.details + +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.BlockchainAddress +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events.AssetScreen.AssetScreenButton +import com.tonapps.bus.generated.Events.AssetScreen.AssetScreenFrom +import com.tonapps.core.helper.ExternalLinkHelper +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.MviRelay +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.trading.asTokenEntity +import com.tonapps.trading.data.TradingRepository +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.wallet.data.events.EventsRepository +import com.tonapps.wallet.data.tx.TransactionManager +import com.tonapps.wallet.data.events.tx.model.TxEvent +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.staking.StakingRepository +import com.tonapps.wallet.data.token.TokenRepository +import com.tonapps.wallet.data.token.entities.AccountTokenEntity +import com.tonapps.wallet.features.events.TxEventUiMapper +import io.tradingapi.models.AssetDetailsResponse +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import uikit.chart.ChartPeriod +import uikit.chart.ChartPoint +import uikit.extensions.collectFlow + +sealed interface AssetDetailsAction : MviAction { + data object Init : AssetDetailsAction + data object RefreshBalanceAndHistory : AssetDetailsAction + data class SetChartPeriod(val period: ChartPeriod) : AssetDetailsAction + data object OpenTokenExplorer : AssetDetailsAction +} + +sealed interface AssetDetailsEvent { + data class OpenUrl(val url: String) : AssetDetailsEvent +} + +sealed interface AssetDetailsState : MviState { + data object Loading : AssetDetailsState + data object Error : AssetDetailsState + data class Data( + val details: AssetDetailsResponse, + val currencyCode: String, + val sections: AssetDetailsSections, + val chartData: List = emptyList(), + val chartPeriod: ChartPeriod = AssetDetailsFeature.InitialChartPeriod, + val isChartLoading: Boolean = false, + val hiddenBalances: Boolean = false, + val maxStakingApyFormatted: String? = null, + ) : AssetDetailsState +} + +class AssetDetailsViewState( + val global: MviProperty, +) : MviViewState + +@OptIn(ExperimentalCoroutinesApi::class) +class AssetDetailsFeature( + private val tradingRepository: TradingRepository, + private val tokenRepository: TokenRepository, + private val accountRepository: AccountRepository, + private val settingsRepository: SettingsRepository, + private val eventsRepository: EventsRepository, + private val stakingRepository: StakingRepository, + private val txEventUiMapper: TxEventUiMapper, + private val transactionManager: TransactionManager, + private val api: API, + val assetId: String, + private val from: AssetScreenFrom, +) : MviFeature( + initState = AssetDetailsState.Loading, + initAction = AssetDetailsAction.Init, +) { + + companion object { + val InitialChartPeriod: ChartPeriod = ChartPeriod.day + } + + private val relay = MviRelay() + val events: Flow = relay.events + + init { + AnalyticsHelper.Default.events.assetScreen.assetView(from = from, asset = assetId) + collectFlow( + accountRepository.selectedWalletFlow.flatMapLatest { wallet -> + transactionManager.eventsFlow(wallet) + }, + ) { + sendAction(AssetDetailsAction.RefreshBalanceAndHistory) + } + collectFlow(transactionManager.tronUpdatedFlow) { + sendAction(AssetDetailsAction.RefreshBalanceAndHistory) + } + } + + fun trackButtonClick(button: AssetScreenButton) { + AnalyticsHelper.Default.events.assetScreen.assetButtonClick( + button = button, + asset = assetId + ) + } + + override fun createViewState(): AssetDetailsViewState { + return buildViewState { + AssetDetailsViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: AssetDetailsAction) { + when (action) { + AssetDetailsAction.Init -> load() + AssetDetailsAction.RefreshBalanceAndHistory -> refreshBalanceAndHistory() + is AssetDetailsAction.SetChartPeriod -> { + val data = obtainSpecificState() ?: return + if (data.chartPeriod == action.period && !data.isChartLoading) return + loadChart(data.details, action.period) + } + + AssetDetailsAction.OpenTokenExplorer -> { + val url = getExplorerUrl() ?: return + relay.emit(AssetDetailsEvent.OpenUrl(url = url)) + } + } + } + + private suspend fun load() { + setState { AssetDetailsState.Loading } + try { + val details = tradingRepository.getAssetDetails(assetId) + val currencyCode = tradingRepository.getCurrency() + val accountToken = getAccountToken(details) + val recentEvents = loadTokenTransactions(details) + val maxStakingApyFormatted = loadMaxStakingApy(details) + val sections = AssetDetailsSections.build( + details = details, + currencyCode = currencyCode, + hiddenBalances = settingsRepository.hiddenBalances, + accountToken = accountToken, + recentEvents = recentEvents, + ) + setState { + AssetDetailsState.Data( + details = details, + currencyCode = currencyCode, + sections = sections, + chartPeriod = InitialChartPeriod, + isChartLoading = true, + maxStakingApyFormatted = maxStakingApyFormatted, + ) + } + loadChart(details, InitialChartPeriod) + } catch (e: Throwable) { + L.e(e) + setState { AssetDetailsState.Error } + } + } + + private suspend fun refreshBalanceAndHistory() { + val data = obtainSpecificState() ?: return + val details = data.details + val currencyCode = tradingRepository.getCurrency() + val accountToken = getAccountToken(details, refresh = true) + val recentEvents = loadTokenTransactions(details) + val sections = AssetDetailsSections.build( + details = details, + currencyCode = currencyCode, + hiddenBalances = settingsRepository.hiddenBalances, + accountToken = accountToken, + recentEvents = recentEvents, + ) + setState { + val d = this as? AssetDetailsState.Data ?: return@setState this + d.copy(currencyCode = currencyCode, sections = sections) + } + } + + private suspend fun loadMaxStakingApy(details: AssetDetailsResponse): String? { + return try { + val token = details.asset.asTokenEntity + if (!token.isTon) return null + val wallet = accountRepository.getSelectedWallet() ?: return null + val staking = stakingRepository.get( + accountId = wallet.accountId, + network = wallet.network, + initializedAccount = wallet.initialized, + ) + val enabledStaking = api.getConfig(wallet.network).enabledStaking + val maxApy = staking.pools + .filter { enabledStaking.contains(it.implementation.title) } + .maxOfOrNull { it.apy } + ?: return null + CurrencyFormatter.formatPercent(maxApy).toString() + } catch (e: Throwable) { + L.e(e) + null + } + } + + private suspend fun getAccountToken(details: AssetDetailsResponse, refresh: Boolean = false): AccountTokenEntity? { + return try { + val wallet = accountRepository.requiredSelectedWallet() + val tokens = tokenRepository.get( + currency = settingsRepository.currency, + accountId = wallet.accountId, + network = wallet.network, + refresh = refresh, + ) ?: throw IllegalStateException("Tokens not found for account ${wallet.accountId}") + val tokenAddress = details.asset.asTokenEntity.address + if (tokenAddress.equals(WalletCurrency.TON_CHAIN_KEY, ignoreCase = true)) { + return tokens.firstOrNull { it.isTon } + } + return tokens.firstOrNull { it.address.equalsAddress(tokenAddress) } + } catch (e: Throwable) { + L.e(e) + null + } + } + + private suspend fun loadChart(details: AssetDetailsResponse, period: ChartPeriod) { + setState { + val data = this as? AssetDetailsState.Data ?: return@setState this + data.copy(isChartLoading = true) + } + val token = details.asset.asTokenEntity + val address = if (token.isUsdtTrc20) { + TokenEntity.USDT.address + } else { + token.address + } + try { + val chart = tradingRepository.getAssetChart(address, period) + setState { + val data = this as? AssetDetailsState.Data ?: return@setState this + data.copy( + chartData = chart, + chartPeriod = period, + isChartLoading = false, + ) + } + } catch (e: Throwable) { + L.e(e) + setState { + val data = this as? AssetDetailsState.Data ?: return@setState this + data.copy(isChartLoading = false) + } + } + } + + private suspend fun loadTokenTransactions(details: AssetDetailsResponse): List? { + try { + val wallet = accountRepository.getSelectedWallet() + ?: throw IllegalStateException("Selected wallet not found") + val token = details.asset.asTokenEntity + val txEvents = when (token.blockchain) { + Blockchain.TON -> loadTonTokenTransactions(wallet, token.address) + Blockchain.TRON -> loadTronTokenTransactions(wallet) + } ?: return emptyList() + if (txEvents.isEmpty()) return null + return txEvents.map { + AssetDetailsSections.RecentEvents.Item( + txEvent = it, + uiEvent = txEventUiMapper.toUiItem(it, wallet) + ) + } + } catch (e: Throwable) { + L.e(e) + return null + } + } + + private suspend fun loadTonTokenTransactions( + wallet: WalletEntity, + tokenAddress: String, + ): List? { + val accountEvents = eventsRepository.loadForToken( + tokenAddress = tokenAddress, + accountId = wallet.accountId, + network = wallet.network, + limit = 4, + ) ?: return null + val tonAddress = BlockchainAddress( + value = wallet.address, + network = wallet.network, + blockchain = Blockchain.TON, + ) + return eventsRepository.mapAccountEventsToTxEvents( + tonAddress, + accountEvents.events, + ) + } + + private suspend fun loadTronTokenTransactions( + wallet: WalletEntity, + ): List? { + if (!wallet.hasPrivateKey || wallet.testnet) return null + val tronAddress = accountRepository.getTronAddress(wallet.id) ?: return null + val tonProofToken = accountRepository.requestTonProofToken(wallet) ?: return null + val tronEvents = eventsRepository.loadTronEvents( + tronWalletAddress = tronAddress, + tonProofToken = tonProofToken, + limit = 4, + ) ?: return null + val tronBlockchainAddress = BlockchainAddress( + value = tronAddress, + network = wallet.network, + blockchain = Blockchain.TRON, + ) + return eventsRepository.mapTronEventsToTxEvents( + tronBlockchainAddress, + tronEvents, + ) + } + + private suspend fun getExplorerUrl(): String? { + try { + val data = obtainSpecificState() ?: throw IllegalStateException( + "Data state not found" + ) + val token = data.details.asset.asTokenEntity + val wallet = accountRepository.requiredSelectedWallet() + val tronAddress = accountRepository.getTronAddress(wallet.id) ?: "" + val accountExplorerUrl = api.getConfig(wallet.network).accountExplorer + return if (token.isUsdtTrc20) { + ExternalLinkHelper.tronToken(tronAddress, wallet.testnet) + } else if (token.isTon) { + accountExplorerUrl.format(wallet.address) + } else { + accountExplorerUrl.format("${wallet.address}/jetton/${token.address}") + } + } catch (e: Throwable) { + L.e(e) + return null + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsInfoModal.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsInfoModal.kt new file mode 100644 index 000000000..03c545809 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsInfoModal.kt @@ -0,0 +1,148 @@ +package com.tonapps.trading.screens.details + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.ButtonSizeLarge +import ui.components.moon.MoonAccentButton +import ui.components.moon.MoonTopAppBarSimple +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.MoonTextContentCell +import ui.components.moon.dialog.MoonModalDialog +import ui.components.moon.dialog.rememberDialogNavigator +import ui.preview.ThemedPreview +import ui.theme.UIKit + +internal enum class AssetInfoModalType { + Stock, + Etf, +} + +private data class AssetInfoContent( + @StringRes val titleRes: Int, + @StringRes val descriptionRes: Int, + @StringRes val firstBulletRes: Int, + @StringRes val secondBulletRes: Int, + @StringRes val thirdBulletRes: Int, +) + +@Composable +internal fun AssetDetailsInfoModal( + type: AssetInfoModalType, + onDismiss: () -> Unit, +) { + val navigator = rememberDialogNavigator(onClose = onDismiss) + + val content = type.content() + + MoonModalDialog(navigator = navigator) { + MoonTopAppBarSimple( + title = "", + actionIconRes = UIKitIcon.ic_close_16, + onActionClick = { navigator.close() }, + backgroundColor = Color.Transparent, + ) + MoonTextContentCell( + modifier = Modifier.padding(horizontal = 16.dp), + title = stringResource(content.titleRes), + description = stringResource(content.descriptionRes), + descriptionStyle = UIKit.typography.body2, + ) + Spacer(modifier = Modifier.height(32.dp)) + MoonBundleCell { + Column(modifier = Modifier.padding(vertical = 12.dp)) { + Bullet(stringResource(content.firstBulletRes)) + Bullet(stringResource(content.secondBulletRes)) + Bullet(stringResource(content.thirdBulletRes)) + } + } + Spacer(modifier = Modifier.height(16.dp)) + MoonAccentButton( + modifier = Modifier.fillMaxWidth().padding(16.dp), + text = stringResource(Localization.ok), + size = ButtonSizeLarge, + onClick = { navigator.close() }, + ) + } +} + +@Composable +private fun Bullet( + text: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + modifier = Modifier.padding(start = 4.dp), + text = "\u2022", + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.primary, + ) + Text( + modifier = Modifier.weight(1f), + text = text, + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.primary, + ) + } +} + +private fun AssetInfoModalType.content(): AssetInfoContent = when (this) { + AssetInfoModalType.Stock -> AssetInfoContent( + titleRes = Localization.asset_info_modal_stock_title, + descriptionRes = Localization.asset_info_modal_stock_description, + firstBulletRes = Localization.asset_info_modal_stock_bullet_1, + secondBulletRes = Localization.asset_info_modal_stock_bullet_2, + thirdBulletRes = Localization.asset_info_modal_stock_bullet_3, + ) + + AssetInfoModalType.Etf -> AssetInfoContent( + titleRes = Localization.asset_info_modal_etf_title, + descriptionRes = Localization.asset_info_modal_etf_description, + firstBulletRes = Localization.asset_info_modal_etf_bullet_1, + secondBulletRes = Localization.asset_info_modal_etf_bullet_2, + thirdBulletRes = Localization.asset_info_modal_etf_bullet_3, + ) +} + +@Preview +@Composable +private fun AssetDetailsInfoModalStockPreview() { + ThemedPreview { + AssetDetailsInfoModal( + type = AssetInfoModalType.Stock, + onDismiss = {} + ) + } +} + +@Preview +@Composable +private fun AssetDetailsInfoModalEtfPreview() { + ThemedPreview { + AssetDetailsInfoModal( + type = AssetInfoModalType.Etf, + onDismiss = {} + ) + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsScreen.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsScreen.kt new file mode 100644 index 000000000..427fde888 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsScreen.kt @@ -0,0 +1,789 @@ +package com.tonapps.trading.screens.details + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.bus.generated.Events.AssetScreen.AssetScreenButton +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.trading.asTokenEntity +import com.tonapps.trading.assetChainImageUrl +import com.tonapps.trading.isTokenized +import com.tonapps.trading.omnistonBuyTokens +import com.tonapps.trading.omnistonSellTokens +import com.tonapps.trading.percentDiffColor +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.data.events.tx.model.TxEvent +import com.tonapps.wallet.localization.Localization +import io.tradingapi.models.AssetRef.Verification +import io.tradingapi.models.AssetType +import ui.components.events.EventItem +import ui.components.events.EventItemClickPart +import ui.components.moon.MoonActionIcon +import ui.components.moon.moonBottomBarHeight +import ui.components.moon.MoonDivider +import ui.components.moon.MoonExpandableText +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonTag +import ui.components.moon.MoonTags +import ui.components.moon.MoonTextShimmer +import ui.components.moon.MoonTopAppBar +import ui.components.moon.cell.MoonBundleCell +import ui.components.moon.cell.TextCell +import ui.components.moon.container.BadgeDirection +import ui.components.moon.container.MoonCutBadgedBox +import ui.components.moon.container.MoonScaffold +import ui.components.moon.screen.MoonEmptyScreen +import ui.components.moon.screen.MoonEmptyScreenType +import ui.components.popup.ActionMenu +import ui.components.popup.ActionMenuItem +import ui.components.popup.MoonIconTooltip +import ui.painterResource +import ui.preview.ThemedPreview +import ui.theme.UIKit +import ui.utils.toRichSpanStyle +import uikit.chart.ChartPeriod +import uikit.chart.ChartPoint + +@Composable +fun AssetDetailsScreen( + feature: AssetDetailsFeature, + previewName: String, + previewImageUrl: String, + onOpenToken: (token: TokenEntity, eventsOnly: Boolean) -> Unit, + onOpenUrl: (String) -> Unit, + onOpenSwap: (fromToken: String, toToken: String) -> Unit, + onOpenSend: (tokenAddress: String) -> Unit, + onOpenReceive: (token: TokenEntity?) -> Unit, + onOpenTxDetails: (tx: TxEvent, actionIndex: Int) -> Unit, + onOpenStaking: () -> Unit, + onOpenUnverifiedInfo: () -> Unit, + onBack: () -> Unit, +) { + val state by feature.state.global.observeSafeState() + + LaunchedEffect(feature) { + feature.events.collect { event -> + when (event) { + is AssetDetailsEvent.OpenUrl -> onOpenUrl(event.url) + } + } + } + + AssetDetailsContent( + state = state, + assetId = feature.assetId, + previewName = previewName, + previewImageUrl = previewImageUrl, + onChartPeriodChange = { feature.sendAction(AssetDetailsAction.SetChartPeriod(it)) }, + onOpenToken = onOpenToken, + onOpenUrl = onOpenUrl, + onOpenSwap = onOpenSwap, + onOpenSend = onOpenSend, + onOpenReceive = onOpenReceive, + onOpenTxDetails = onOpenTxDetails, + onOpenExplorer = { feature.sendAction(AssetDetailsAction.OpenTokenExplorer) }, + onOpenStaking = onOpenStaking, + onOpenUnverifiedInfo = onOpenUnverifiedInfo, + onTrackButtonClick = { feature.trackButtonClick(it) }, + onBack = onBack, + ) +} + +@Composable +private fun AssetDetailsContent( + state: AssetDetailsState, + assetId: String, + previewName: String, + previewImageUrl: String, + onChartPeriodChange: (ChartPeriod) -> Unit, + onOpenToken: (token: TokenEntity, eventsOnly: Boolean) -> Unit, + onOpenUrl: (String) -> Unit, + onOpenSwap: (fromToken: String, toToken: String) -> Unit, + onOpenSend: (tokenAddress: String) -> Unit, + onOpenReceive: (token: TokenEntity?) -> Unit, + onOpenTxDetails: (tx: TxEvent, actionIndex: Int) -> Unit, + onOpenExplorer: () -> Unit, + onOpenStaking: () -> Unit, + onOpenUnverifiedInfo: () -> Unit, + onTrackButtonClick: (AssetScreenButton) -> Unit = {}, + onBack: () -> Unit, +) { + val data = state as? AssetDetailsState.Data + val asset = data?.details?.asset + val isNativeGram = assetId.startsWith("ton/") && assetId.substringAfterLast("/") == "coin" + val title = if (isNativeGram) { + stringResource(Localization.toncoin) + } else { + asset?.name ?: previewName + } + val isUnverified = asset != null && asset.verification != Verification.whitelist + val subtitle = when { + asset?.assetType == AssetType.stocks -> stringResource(Localization.tokenized_stocks) + asset?.assetType == AssetType.etfs -> stringResource(Localization.tokenized_etfs) + isUnverified -> stringResource(Localization.unverified_token) + else -> null + } + val imageUrl = previewImageUrl.ifBlank { asset?.imageUrl } ?: "" + val chainImageUrl = when { + asset?.isTokenized() == true -> null + else -> asset?.id?.assetChainImageUrl() + } + + val scrollState = rememberScrollState() + val showDivider by remember { + derivedStateOf { scrollState.value > 0 } + } + + var infoModalType by remember { mutableStateOf(null) } + var topActionMenuExpanded by remember { mutableStateOf(false) } + + val onTitleClick = { + if (isUnverified) { + onOpenUnverifiedInfo() + } else { + infoModalType = when { + asset?.assetType == AssetType.stocks -> AssetInfoModalType.Stock + asset?.assetType == AssetType.etfs -> AssetInfoModalType.Etf + else -> null + } + } + } + + val subtitleColor = if (isUnverified) { + UIKit.colorScheme.accent.orange + } else { + UIKit.colorScheme.text.accent + } + + MoonScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()), + topBar = { + MoonTopAppBar( + modifier = Modifier.statusBarsPadding(), + title = title, + onTitleClick = onTitleClick, + subtitleContent = subtitle?.let { + { + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = subtitle, + color = subtitleColor, + style = UIKit.typography.body2, + ) + Spacer(modifier = Modifier.width(4.dp)) + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_information_circle_12), + color = subtitleColor, + ) + } + } + }, + navigationIconRes = UIKitIcon.ic_chevron_left_16, + onNavigationClick = onBack, + hasCustomActions = state is AssetDetailsState.Data, + actions = { + if (state is AssetDetailsState.Data) { + Box { + MoonActionIcon( + painter = painterResource(UIKitIcon.ic_ellipsis_16), + onClick = { topActionMenuExpanded = true }, + tintColor = UIKit.colorScheme.buttonSecondary.primaryForeground, + backgroundColor = UIKit.colorScheme.buttonSecondary.primaryBackground, + ) + ActionMenu( + expanded = topActionMenuExpanded, + onDismissRequest = { topActionMenuExpanded = false }, + ) { + ActionMenuItem( + text = stringResource(Localization.view_details), + painter = painterResource(UIKitIcon.ic_globe_16), + onClick = { + topActionMenuExpanded = false + onOpenExplorer() + } + ) + } + } + } + }, + showDivider = showDivider, + ) + }, + ) { + if (state is AssetDetailsState.Error) { + MoonEmptyScreen( + modifier = Modifier.fillMaxSize(), + type = MoonEmptyScreenType.Error, + text = stringResource(Localization.cant_find_anything), + ) + } else { + val bottomOverlayHeight = moonBottomBarHeight() + Box(modifier = Modifier.fillMaxSize()) { + AssetDetailsData( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(bottom = bottomOverlayHeight), + sections = data?.sections, + imageUrl = imageUrl, + chainImageUrl = chainImageUrl, + currencyCode = data?.currencyCode ?: "", + chartData = data?.chartData ?: emptyList(), + chartPeriod = data?.chartPeriod ?: AssetDetailsFeature.InitialChartPeriod, + maxStakingApyFormatted = data?.maxStakingApyFormatted, + onOpenToken = onOpenToken, + onOpenUrl = onOpenUrl, + onChartPeriodChange = onChartPeriodChange, + onOpenTxDetails = onOpenTxDetails, + onOpenStaking = onOpenStaking, + showChart = true, + ) + AssetDetailsBottomBar( + modifier = Modifier.align(Alignment.BottomCenter), + hasBalance = data?.sections?.balance != null, + onBuyClick = { + val current = data ?: return@AssetDetailsBottomBar + onTrackButtonClick(AssetScreenButton.Buy) + val (from, to) = current.details.asset.omnistonBuyTokens() + onOpenSwap(from, to) + }, + onSellClick = { + val current = data ?: return@AssetDetailsBottomBar + onTrackButtonClick(AssetScreenButton.Sell) + val (from, to) = current.details.asset.omnistonSellTokens() + onOpenSwap(from, to) + }, + onSendClick = { + val current = data ?: return@AssetDetailsBottomBar + onTrackButtonClick(AssetScreenButton.Send) + onOpenSend(current.details.asset.asTokenEntity.address) + }, + onReceiveClick = { + val current = data ?: return@AssetDetailsBottomBar + onTrackButtonClick(AssetScreenButton.Receive) + onOpenReceive(current.details.asset.asTokenEntity) + }, + ) + } + } + } + + infoModalType?.let { modalType -> + AssetDetailsInfoModal( + type = modalType, + onDismiss = { infoModalType = null }, + ) + } +} + +@Composable +private fun AssetDetailsData( + modifier: Modifier = Modifier, + sections: AssetDetailsSections?, + imageUrl: String, + chainImageUrl: String?, + currencyCode: String, + chartData: List, + chartPeriod: ChartPeriod, + maxStakingApyFormatted: String? = null, + onOpenToken: (token: TokenEntity, eventsOnly: Boolean) -> Unit, + onOpenUrl: (String) -> Unit, + onChartPeriodChange: (ChartPeriod) -> Unit, + onOpenTxDetails: (tx: TxEvent, actionIndex: Int) -> Unit, + onOpenStaking: () -> Unit, + showChart: Boolean = true, +) { + Column(modifier = modifier) { + if (showChart) { + ChartSection( + imageUrl = imageUrl, + chainImageUrl = chainImageUrl, + maxStakingApyFormatted = maxStakingApyFormatted, + currencyCode = currencyCode, + chartData = chartData, + chartPeriod = chartPeriod, + onPeriodChange = onChartPeriodChange, + onOpenStaking = onOpenStaking, + ) + MoonDivider() + } + if (sections == null) { + DetailsBalanceSectionShimmer() + } else { + sections.balance?.let { + DetailsBalanceSection( + balance = it, + imageUrl = imageUrl, + chainImageUrl = chainImageUrl, + ) + } + } + sections?.recentEvents?.let { + EventsSection( + recentEvents = it, + onOpenToken = onOpenToken, + onOpenTxDetails = onOpenTxDetails, + ) + } + sections?.about?.let { DetailsAboutSection(about = it) } + sections?.overview?.let { DetailsOverviewSection(overview = it) } + sections?.trading?.let { DetailsTradingSection(trading = it) } + sections?.links?.let { DetailsLinksSection(links = it, onOpenUrl = onOpenUrl) } + } +} + +@Composable +private fun DetailsBalanceSection( + balance: AssetDetailsSections.Balance, + imageUrl: String, + chainImageUrl: String?, +) { + Column(modifier = Modifier.fillMaxWidth()) { + SectionTitle(text = stringResource(Localization.your_balance)) + MoonBundleCell { + TextCell( + title = balance.balanceFormatted, + subtitle = balance.fiatFormatted, + image = { + MoonCutBadgedBox( + badge = { + chainImageUrl?.let { + MoonItemImage( + image = chainImageUrl, + size = 20.dp + ) + } + }, + direction = BadgeDirection.EndBottom, + ) { + MoonItemImage( + image = imageUrl, + size = 44.dp, + ) + } + }, + minHeight = 76.dp, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun DetailsBalanceSectionShimmer() { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MoonTextShimmer( + text = stringResource(Localization.your_balance), + style = UIKit.typography.label1, + cornerRadius = 12.dp, + ) + } + MoonBundleCell { + TextCell( + title = { + MoonTextShimmer( + text = "0,000.00 TON", + style = UIKit.typography.label1, + cornerRadius = 8.dp, + backgroundFill = UIKit.colorScheme.background.contentTint, + ) + }, + subtitle = { + MoonTextShimmer( + text = "0,000.00 $", + style = UIKit.typography.body2, + cornerRadius = 8.dp, + backgroundFill = UIKit.colorScheme.background.contentTint, + ) + }, + image = { + Box( + modifier = Modifier + .requiredSize(44.dp) + .background( + color = UIKit.colorScheme.background.contentTint, + shape = CircleShape, + ), + ) + }, + minHeight = 76.dp, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun EventsSection( + recentEvents: AssetDetailsSections.RecentEvents, + onOpenToken: (token: TokenEntity, eventsOnly: Boolean) -> Unit, + onOpenTxDetails: (tx: TxEvent, actionIndex: Int) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + SectionTitle( + text = stringResource(Localization.transaction_history), + onSeeAllClick = if (recentEvents.showSeeAll) { + { onOpenToken(recentEvents.token, true) } + } else { + null + } + ) + MoonBundleCell { + Column { + recentEvents.items.forEachIndexed { index, row -> + if (index > 0) MoonDivider() + EventItem( + event = row.uiEvent, + hiddenBalances = recentEvents.hiddenBalances, + onClick = { _, part -> + if (part is EventItemClickPart.Action) { + onOpenTxDetails(row.txEvent, part.index) + } + }, + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun DetailsAboutSection( + about: AssetDetailsSections.About, +) { + Column(modifier = Modifier.fillMaxWidth()) { + SectionTitle(text = stringResource(Localization.about)) + MoonBundleCell { + MoonExpandableText( + modifier = Modifier.padding(16.dp), + text = about.body, + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.primary, + maxLines = 3, + showMoreText = stringResource(Localization.more), + showMoreColor = UIKit.colorScheme.text.accent, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun DetailsOverviewSection( + overview: AssetDetailsSections.Overview, +) { + Column(modifier = Modifier.fillMaxWidth()) { + SectionTitle(text = stringResource(Localization.overview)) + MoonBundleCell { + Column { + overview.items.forEachIndexed { index, item -> + if (index > 0) MoonDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.wrapContentWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(item.titleRes), + style = UIKit.typography.body1, + color = UIKit.colorScheme.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + MoonIconTooltip(text = stringResource(item.tooltipTextRes)) + } + Text( + text = item.valueFormatted, + style = UIKit.typography.label1, + color = UIKit.colorScheme.text.primary, + maxLines = 1, + ) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun DetailsTradingSection( + trading: AssetDetailsSections.Trading, +) { + val percentStyle = UIKit.typography.body1 + val volumeChangeFormatted = trading.volumeChange24hFormatted + val percentSpanColor = volumeChangeFormatted?.percentDiffColor() + + Column(modifier = Modifier.fillMaxWidth()) { + SectionTitle(text = stringResource(Localization.trading_activity)) + MoonBundleCell { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(Localization.volume), + style = UIKit.typography.body1, + color = UIKit.colorScheme.text.secondary + ) + MoonIconTooltip(text = stringResource(Localization.volume_tooltip)) + } + Text( + text = buildAnnotatedString { + append(trading.volume24hFormatted) + if (volumeChangeFormatted != null && percentSpanColor != null) { + append(" ") + withStyle(percentStyle.toRichSpanStyle(color = percentSpanColor)) { + append(volumeChangeFormatted) + } + } + }, + style = UIKit.typography.label1, + color = UIKit.colorScheme.text.primary + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (trading.buyWeight > 0f) { + Box( + modifier = Modifier + .height(6.dp) + .weight(trading.buyWeight) + .clip(RoundedCornerShape(topStart = 6.dp, bottomStart = 6.dp)) + .background(UIKit.colorScheme.accent.green) + ) + } + if (trading.sellWeight > 0f) { + Box( + modifier = Modifier + .height(6.dp) + .weight(trading.sellWeight) + .clip(RoundedCornerShape(topEnd = 6.dp, bottomEnd = 6.dp)) + .background(UIKit.colorScheme.accent.red) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource( + Localization.trading_activity_buy, + trading.buy24hFormatted + ), + style = UIKit.typography.body2, + color = UIKit.colorScheme.accent.green + ) + Text( + text = stringResource( + Localization.trading_activity_sell, + trading.sell24hFormatted + ), + style = UIKit.typography.body2, + color = UIKit.colorScheme.accent.red + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + modifier = Modifier.padding(horizontal = 16.dp), + style = UIKit.typography.body3, + color = UIKit.colorScheme.text.tertiary, + text = stringResource(Localization.trading_activity_note), + ) + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun DetailsLinksSection( + links: AssetDetailsSections.Links, + onOpenUrl: (String) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + SectionTitle(text = stringResource(Localization.links)) + MoonTags(modifier = Modifier.padding(horizontal = 16.dp)) { + links.items.forEach { item -> + MoonTag( + text = item.name, + icon = painterResource(item.iconRes), + onClick = { onOpenUrl(item.url) }, + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +private val previewChartData = listOf( + ChartPoint(date = 1_700_000_000L, price = 2.10f), + ChartPoint(date = 1_700_086_400L, price = 2.35f), + ChartPoint(date = 1_700_172_800L, price = 2.20f), + ChartPoint(date = 1_700_259_200L, price = 2.55f), + ChartPoint(date = 1_700_345_600L, price = 2.80f), + ChartPoint(date = 1_700_432_000L, price = 2.65f), + ChartPoint(date = 1_700_518_400L, price = 3.05f), +) + +private val previewAssetDetailsSections = AssetDetailsSections( + balance = AssetDetailsSections.Balance( + token = TokenEntity.TON, + balanceFormatted = "123.45 TON", + fiatFormatted = "$456.78", + ), + about = AssetDetailsSections.About( + body = "Toncoin is the native cryptocurrency of TON blockchain.", + ), + overview = AssetDetailsSections.Overview( + items = listOf( + AssetDetailsSections.OverviewItem( + titleRes = Localization.market_cap, + tooltipTextRes = Localization.market_cap_tooltip, + valueFormatted = "$1.2B", + ), + AssetDetailsSections.OverviewItem( + titleRes = Localization.total_supply, + tooltipTextRes = Localization.total_supply_tooltip, + valueFormatted = "5.1B TON", + ), + AssetDetailsSections.OverviewItem( + titleRes = Localization.circulating_supply, + tooltipTextRes = Localization.circulating_supply_tooltip, + valueFormatted = "4.8B TON", + ), + ), + ), + trading = AssetDetailsSections.Trading( + volume24hFormatted = "$5.4M", + volumeChange24hFormatted = "+3.21%", + buyWeight = 0.55f, + sellWeight = 0.45f, + buy24hFormatted = "$3.0M", + sell24hFormatted = "$2.4M", + ), + recentEvents = null, + links = AssetDetailsSections.Links( + items = listOf( + AssetDetailsSections.LinkItem( + name = "Website", + iconRes = UIKitIcon.ic_globe_16, + url = "https://ton.org", + ), + AssetDetailsSections.LinkItem( + name = "Telegram", + iconRes = UIKitIcon.ic_telegram_16, + url = "https://t.me/toncoin", + ), + ), + ), +) + +@Preview +@Composable +private fun AssetDetailsScreenPreview() { + ThemedPreview { + MoonScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(rememberNestedScrollInteropConnection()) + .statusBarsPadding(), + title = "Toncoin", + onBack = {}, + ) { + val bottomOverlayHeight = moonBottomBarHeight() + Box(modifier = Modifier.fillMaxSize()) { + AssetDetailsData( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(bottom = bottomOverlayHeight), + sections = previewAssetDetailsSections, + imageUrl = "", + chainImageUrl = null, + currencyCode = "USD", + chartData = previewChartData, + chartPeriod = ChartPeriod.month, + onOpenToken = { _, _ -> }, + onOpenUrl = {}, + onChartPeriodChange = {}, + onOpenTxDetails = { _, _ -> }, + onOpenStaking = {}, + showChart = true, + ) + AssetDetailsBottomBar( + modifier = Modifier.align(Alignment.BottomCenter), + hasBalance = true, + ) + } + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsSections.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsSections.kt new file mode 100644 index 000000000..54785e3e3 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/AssetDetailsSections.kt @@ -0,0 +1,252 @@ +package com.tonapps.trading.screens.details + +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.icu.Coins +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.icu.Formatter +import com.tonapps.trading.asTokenEntity +import com.tonapps.trading.iconRes +import com.tonapps.wallet.data.events.tx.model.TxEvent +import com.tonapps.wallet.data.token.entities.AccountTokenEntity +import com.tonapps.wallet.localization.Localization +import io.tradingapi.models.AssetDetailsResponse +import ui.components.events.UiEvent +import io.tradingapi.models.SectionLinks +import io.tradingapi.models.SectionTradingActivity +import java.math.BigDecimal + +data class AssetDetailsSections( + val balance: Balance?, + val about: About?, + val overview: Overview?, + val trading: Trading?, + val recentEvents: RecentEvents?, + val links: Links?, +) { + data class Balance( + val token: TokenEntity, + val balanceFormatted: String, + val fiatFormatted: String, + ) + + data class About( + val body: String, + ) + + data class Overview( + val items: List, + ) + + data class OverviewItem( + val titleRes: Int, + val tooltipTextRes: Int, + val valueFormatted: String, + ) + + data class Trading( + val volume24hFormatted: String, + val volumeChange24hFormatted: String?, + val buyWeight: Float, + val sellWeight: Float, + val buy24hFormatted: String, + val sell24hFormatted: String, + ) + + data class RecentEvents( + val items: List, + val showSeeAll: Boolean, + val hiddenBalances: Boolean, + val token: TokenEntity, + ) { + data class Item( + val txEvent: TxEvent, + val uiEvent: UiEvent.Item, + ) + } + + data class Links( + val items: List, + ) + + data class LinkItem( + val name: String, + val iconRes: Int, + val url: String, + ) + + companion object { + + fun build( + details: AssetDetailsResponse, + currencyCode: String, + hiddenBalances: Boolean, + accountToken: AccountTokenEntity?, + recentEvents: List?, + ): AssetDetailsSections { + return AssetDetailsSections( + balance = buildBalance(accountToken), + about = buildAbout(details), + overview = buildOverview(details, currencyCode), + trading = buildTrading(details.sections.tradingActivity, currencyCode), + recentEvents = buildRecentEvents(details, hiddenBalances, recentEvents), + links = buildLinks(details.sections.links), + ) + } + + private fun buildBalance( + accountToken: AccountTokenEntity?, + ): Balance? { + val token = accountToken ?: return null + return Balance( + token = token.token, + balanceFormatted = CurrencyFormatter.format( + currency = token.token.symbol, + value = token.balance.value, + ).toString(), + fiatFormatted = CurrencyFormatter.formatFiat( + currency = token.fiatCurrency.code, + value = token.fiat, + ).toString(), + ) + } + + private fun buildAbout(details: AssetDetailsResponse): About? { + val about = details.sections.about + val text = about.text?.trim() ?: return null + if (!about.enabled || text.isBlank()) return null + return About(body = text) + } + + private fun buildOverview( + details: AssetDetailsResponse, + currencyCode: String, + ): Overview? { + val overview = details.sections.overview + if (!overview.enabled) return null + val decimals = details.asset.decimals + val marketCapFormatted = overview.marketCap?.let { + CurrencyFormatter.formatFiat( + currency = currencyCode, + value = BigDecimal(it), + compact = true, + ).toString() + } + val totalSupplyFormatted = overview.totalSupply?.let { + CurrencyFormatter.format( + currency = "", + value = Coins.ofNano(it, decimals), + compact = true, + ).toString() + } + val circulatingSupplyFormatted = overview.circulatingSupply?.let { + CurrencyFormatter.format( + currency = "", + value = Coins.ofNano(it, decimals), + compact = true, + ).toString() + } + val items = buildList { + marketCapFormatted?.let { + add( + OverviewItem( + titleRes = Localization.market_cap, + tooltipTextRes = Localization.market_cap_tooltip, + valueFormatted = it, + ), + ) + } + totalSupplyFormatted?.let { + add( + OverviewItem( + titleRes = Localization.total_supply, + tooltipTextRes = Localization.total_supply_tooltip, + valueFormatted = it, + ), + ) + } + circulatingSupplyFormatted?.let { + add( + OverviewItem( + titleRes = Localization.circulating_supply, + tooltipTextRes = Localization.circulating_supply_tooltip, + valueFormatted = it, + ), + ) + } + } + if (items.isEmpty()) return null + return Overview(items = items) + } + + private fun buildTrading( + tradingActivity: SectionTradingActivity, + currencyCode: String, + ): Trading? { + if (!tradingActivity.enabled) return null + val totalValue = tradingActivity.volume24h?.let { BigDecimal(it) } ?: return null + val buyValue = tradingActivity.buy24h?.let { BigDecimal(it) } ?: return null + val sellValue = tradingActivity.sell24h?.let { BigDecimal(it) } ?: return null + if (totalValue.compareTo(BigDecimal.ZERO) == 0) return null + + val buyWeight = (buyValue / totalValue).toFloat().coerceIn(0f, 1f) + val sellWeight = (sellValue / totalValue).toFloat().coerceIn(0f, 1f) + + val volume24hFormatted = CurrencyFormatter.formatFiat( + currency = currencyCode, + value = totalValue, + compact = true, + ).toString() + val buy24hFormatted = CurrencyFormatter.formatFiat( + currency = currencyCode, + value = buyValue, + compact = true, + ).toString() + val sell24hFormatted = CurrencyFormatter.formatFiat( + currency = currencyCode, + value = sellValue, + compact = true, + ).toString() + + val volumeChange24hFormatted = + tradingActivity.volumeChange24h?.let { Formatter.percent(it) } + + return Trading( + volume24hFormatted = volume24hFormatted, + volumeChange24hFormatted = volumeChange24hFormatted, + buyWeight = buyWeight, + sellWeight = sellWeight, + buy24hFormatted = buy24hFormatted, + sell24hFormatted = sell24hFormatted, + ) + } + + private fun buildLinks(sectionLinks: SectionLinks): Links? { + val items = sectionLinks.items ?: return null + if (!sectionLinks.enabled || items.isEmpty()) return null + return Links( + items = items.map { item -> + LinkItem( + name = item.name, + iconRes = item.type.iconRes(), + url = item.url, + ) + }, + ) + } + + private fun buildRecentEvents( + details: AssetDetailsResponse, + hiddenBalances: Boolean, + recentEvents: List?, + ): RecentEvents? { + if (recentEvents == null || recentEvents.isEmpty()) return null + val token = details.asset.asTokenEntity + return RecentEvents( + items = recentEvents.take(3), + showSeeAll = recentEvents.size > 3, + hiddenBalances = hiddenBalances, + token = token, + ) + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/detailsComponents.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/detailsComponents.kt new file mode 100644 index 000000000..6959c12ca --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/details/detailsComponents.kt @@ -0,0 +1,599 @@ +package com.tonapps.trading.screens.details + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.tonapps.extensions.locale +import com.tonapps.icu.CurrencyFormatter +import com.tonapps.icu.Formatter +import com.tonapps.trading.formatChartTime +import com.tonapps.trading.percentDiffColor +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization +import ui.components.moon.ButtonColorsPrimary +import ui.components.moon.ButtonSizeLarge +import ui.components.moon.MoonAccentButton +import ui.components.moon.MoonActionIcon +import ui.components.moon.MoonBottomBar +import ui.components.moon.MoonItemIcon +import ui.components.moon.MoonItemImage +import ui.components.moon.MoonTextShimmer +import ui.components.moon.container.BadgeDirection +import ui.components.moon.container.MoonCutBadgedBox +import ui.components.popup.ActionMenu +import ui.components.popup.ComposeActionItem +import ui.painterResource +import ui.theme.Shapes +import ui.theme.UIKit +import uikit.chart.ChartPeriod +import uikit.chart.ChartPoint +import uikit.chart.ChartView +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + + +@Composable +internal fun SectionTitle( + modifier: Modifier = Modifier, + text: String, + onSeeAllClick: (() -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = UIKit.typography.label1, + color = UIKit.colorScheme.text.primary + ) + if (onSeeAllClick != null) { + Text( + text = stringResource(Localization.see_all), + modifier = Modifier.clickable { onSeeAllClick() }, + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.accent, + ) + } + } +} + +@Composable +internal fun ChartSection( + imageUrl: String, + chainImageUrl: String?, + currencyCode: String, + chartData: List, + chartPeriod: ChartPeriod, + onPeriodChange: (ChartPeriod) -> Unit, + onOpenStaking: () -> Unit, + maxStakingApyFormatted: String? = null, +) { + var selectedPoint by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 16.dp, bottom = 12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (chainImageUrl != null) { + MoonCutBadgedBox( + badge = { + MoonItemImage(image = chainImageUrl, size = 20.dp) + }, + direction = BadgeDirection.EndBottom, + ) { + MoonItemImage(image = imageUrl, size = 56.dp) + } + } else { + MoonItemImage(image = imageUrl, size = 56.dp) + } + + if (maxStakingApyFormatted != null) { + MaxStakingApyLabel( + formattedApy = maxStakingApyFormatted, + onClick = onOpenStaking, + ) + } + } + ChartHeader( + selectedPoint = selectedPoint, + chartData = chartData, + chartPeriod = chartPeriod, + currencyCode = currencyCode, + ) + Spacer(modifier = Modifier.height(4.dp)) + AssetChart( + modifier = Modifier + .fillMaxWidth() + .height(240.dp), + currencyCode = currencyCode, + chartPeriod = chartPeriod, + data = chartData, + isSquare = chartPeriod == ChartPeriod.hour, + onPointSelected = { selectedPoint = it }, + ) + ChartPeriodSelector( + chartPeriod = chartPeriod, + onPeriodSelected = onPeriodChange, + ) + } +} + +@Composable +private fun MaxStakingApyLabel( + formattedApy: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .clip(Shapes.medium) + .background(UIKit.colorScheme.accent.green.copy(alpha = 0.16f)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + MoonItemIcon( + painter = painterResource(UIKitIcon.ic_staking_16), + color = UIKit.colorScheme.accent.green + ) + Text( + text = stringResource(Localization.earn_apy, formattedApy), + style = UIKit.typography.label2, + color = UIKit.colorScheme.accent.green, + ) + } +} + +@Composable +private fun AssetChart( + modifier: Modifier = Modifier, + currencyCode: String, + chartPeriod: ChartPeriod, + data: List, + isSquare: Boolean = false, + onPointSelected: ((ChartPoint?) -> Unit)? = null, +) { + val locale = LocalContext.current.locale + val formatAxisPrice = remember(currencyCode) { + { price: Float -> + CurrencyFormatter.formatFiat(currencyCode, price.toBigDecimal()).toString() + } + } + val formatAxisTime = remember(chartPeriod, locale) { + val formatter = chartAxisGuideTimeFormatter(locale, chartPeriod) + return@remember { ts: Long -> + if (ts <= 0L) { + "" + } else { + formatter.format(Instant.ofEpochSecond(ts).atZone(ZoneId.systemDefault())) + } + } + } + AndroidView( + modifier = modifier, + factory = { context -> ChartView(context) }, + update = { view -> + view.setData(data, isSquare) + view.onPointSelected = onPointSelected + view.formatAxisPrice = formatAxisPrice + view.formatAxisTime = formatAxisTime + }, + ) +} + +@Composable +private fun ChartPeriodSelector( + modifier: Modifier = Modifier, + chartPeriod: ChartPeriod, + onPeriodSelected: (ChartPeriod) -> Unit, +) { + val periods = ChartPeriod.entries + val selectedIndex = periods.indexOf(chartPeriod).let { if (it >= 0) it else 0 } + val secondary = UIKit.colorScheme.buttonSecondary + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .padding(16.dp) + .height(36.dp), + ) { + val tabWidth = maxWidth / periods.size + val pillOffset by animateDpAsState( + targetValue = tabWidth * selectedIndex, + animationSpec = tween(durationMillis = 200), + label = "chartPeriodPill", + ) + + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .offset(x = pillOffset) + .width(tabWidth) + .fillMaxHeight() + .clip(RoundedCornerShape(18.dp)) + .background(secondary.primaryBackground), + ) + Row(modifier = Modifier.fillMaxSize()) { + periods.forEach { period -> + val interactionSource = remember(period) { MutableInteractionSource() } + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { onPeriodSelected(period) }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = period.title, + style = UIKit.typography.label2, + color = secondary.primaryForeground, + ) + } + } + } + } + } +} + +@Composable +private fun ChartDiffRow( + modifier: Modifier = Modifier, + percentText: String, + deltaPriceText: String, + periodCaption: String, + diffColor: Color, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = percentText, + style = UIKit.typography.body2, + color = diffColor, + maxLines = 1, + overflow = TextOverflow.Visible, + ) + Text( + modifier = Modifier.alpha(0.48f), + text = deltaPriceText, + style = UIKit.typography.body2, + color = diffColor, + maxLines = 1, + overflow = TextOverflow.Visible, + ) + Text( + modifier = Modifier.weight(1f), + text = periodCaption, + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private data class RollingDiffDisplay( + val percentValue: Float, + val percentText: String, + val deltaPriceText: String, + val periodCaption: String, + val diffColor: Color, +) + +@Composable +private fun AnimatedChartDiff( + modifier: Modifier = Modifier, + percentValue: Float, + percentText: String, + deltaPriceText: String, + periodCaption: String, +) { + val offsetMotion = tween(durationMillis = 200, easing = FastOutSlowInEasing) + val fadeMotion = tween(durationMillis = 200, easing = FastOutSlowInEasing) + val display = RollingDiffDisplay( + percentValue = percentValue, + percentText = percentText, + deltaPriceText = deltaPriceText, + periodCaption = periodCaption, + diffColor = percentText.percentDiffColor(), + ) + AnimatedContent( + targetState = display, + modifier = modifier + .graphicsLayer { clip = false }, + transitionSpec = { + val upward = targetState.percentValue > initialState.percentValue + if (upward) { + ( + slideInVertically( + animationSpec = offsetMotion, + initialOffsetY = { it / 2 }) + + fadeIn(animationSpec = fadeMotion) + ) togetherWith ( + slideOutVertically( + animationSpec = offsetMotion, + targetOffsetY = { -it / 2 }) + + fadeOut(animationSpec = fadeMotion) + ) using SizeTransform(clip = false) + } else { + ( + slideInVertically( + animationSpec = offsetMotion, + initialOffsetY = { -(it / 2) }) + + fadeIn(animationSpec = fadeMotion) + ) togetherWith ( + slideOutVertically( + animationSpec = offsetMotion, + targetOffsetY = { it / 2 }) + + fadeOut(animationSpec = fadeMotion) + ) using SizeTransform(clip = false) + } + }, + label = "animatedChartDiff", + ) { state -> + ChartDiffRow( + percentText = state.percentText, + deltaPriceText = state.deltaPriceText, + periodCaption = state.periodCaption, + diffColor = state.diffColor, + ) + } +} + +private fun ChartPeriod.showsTimeInChartCaption(): Boolean = + this == ChartPeriod.hour || this == ChartPeriod.day || this == ChartPeriod.week + +private fun ChartPeriod.showsYearInChartCaption(): Boolean = + this == ChartPeriod.year || this == ChartPeriod.halfYear + +@Composable +private fun ChartHeader( + selectedPoint: ChartPoint?, + chartData: List, + chartPeriod: ChartPeriod, + currencyCode: String, +) { + val context = LocalContext.current + val locale = context.locale + val points = chartData.filter { !it.isEmpty } + val lastPoint = points.lastOrNull() + val activePrice = when { + selectedPoint != null && !selectedPoint.isEmpty -> selectedPoint.price + lastPoint != null -> lastPoint.price + else -> null + } + + val priceText = if (activePrice != null) { + CurrencyFormatter.formatFiat(currencyCode, activePrice.toBigDecimal()).toString() + } else { + "" + } + + val formattedChartTime = + if (selectedPoint != null && selectedPoint.date > 0) { + selectedPoint.date.formatChartTime( + locale, + includeTime = chartPeriod.showsTimeInChartCaption(), + includeYear = chartPeriod.showsYearInChartCaption() + ) + } else { + "" + } + val periodOrTimeCaption = formattedChartTime.ifEmpty { + stringResource(chartPeriodCaptionRes(chartPeriod)) + } + + val (percent, deltaPrice) = if (points.isEmpty() || activePrice == null) { + 0f to 0f + } else { + val firstPrice = points.first().price + val percent = (activePrice - firstPrice) / firstPrice * 100f + percent to (activePrice - firstPrice) + } + + val percentText = if (activePrice != null) Formatter.percent(percent) else "" + val deltaPriceText = + CurrencyFormatter.formatFiat(currencyCode, deltaPrice.toBigDecimal()).toString() + + val isScrubbing = selectedPoint != null && !selectedPoint.isEmpty + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (activePrice == null) { + MoonTextShimmer( + text = "$0,000.00", + style = UIKit.typography.h2, + cornerRadius = 100.dp, + ) + MoonTextShimmer( + text = "+0.00% +$0.00 Last month", + style = UIKit.typography.body2, + cornerRadius = 12.dp, + ) + } else { + Text(text = priceText, style = UIKit.typography.h2) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isScrubbing) { + ChartDiffRow( + percentText = percentText, + deltaPriceText = deltaPriceText, + periodCaption = periodOrTimeCaption, + diffColor = percentText.percentDiffColor(), + ) + } else { + AnimatedChartDiff( + modifier = Modifier.fillMaxWidth(), + percentValue = percent, + percentText = percentText, + deltaPriceText = deltaPriceText, + periodCaption = periodOrTimeCaption, + ) + } + } + } + } +} + +private fun chartAxisGuideTimeFormatter(locale: Locale, period: ChartPeriod): DateTimeFormatter { + return when (period) { + ChartPeriod.hour, ChartPeriod.day -> + DateTimeFormatter.ofPattern("HH:mm").withLocale(locale) + + else -> + DateTimeFormatter.ofPattern("d MMM").withLocale(locale) + } +} + +private const val AssetDetailsMoreMenuSend = "send" +private const val AssetDetailsMoreMenuReceive = "receive" + +private fun chartPeriodCaptionRes(period: ChartPeriod): Int = when (period) { + ChartPeriod.hour -> Localization.chart_period_last_hour + ChartPeriod.day -> Localization.chart_period_last_day + ChartPeriod.week -> Localization.chart_period_last_week + ChartPeriod.month -> Localization.chart_period_last_month + ChartPeriod.halfYear -> Localization.chart_period_last_6_months + ChartPeriod.year -> Localization.chart_period_last_year +} + +@Composable +internal fun AssetDetailsBottomBar( + modifier: Modifier = Modifier, + hasBalance: Boolean, + onBuyClick: () -> Unit = {}, + onSellClick: () -> Unit = {}, + onSendClick: () -> Unit = {}, + onReceiveClick: () -> Unit = {}, +) { + var moreMenuExpanded by remember { mutableStateOf(false) } + val moreMenuItems = buildList { + if (hasBalance) { + add( + ComposeActionItem( + id = AssetDetailsMoreMenuSend, + text = stringResource(Localization.send), + iconPainter = painterResource(UIKitIcon.ic_tray_arrow_up_16), + ), + ) + } + add( + ComposeActionItem( + id = AssetDetailsMoreMenuReceive, + text = stringResource(Localization.receive), + iconPainter = painterResource(UIKitIcon.ic_qr_code_16), + ), + ) + } + + MoonBottomBar(modifier = modifier) { + MoonAccentButton( + modifier = Modifier.weight(1f), + text = stringResource(Localization.buy), + size = ButtonSizeLarge, + buttonColors = ButtonColorsPrimary, + onClick = onBuyClick, + ) + if (hasBalance) { + MoonAccentButton( + modifier = Modifier.weight(1f), + text = stringResource(Localization.sell), + size = ButtonSizeLarge, + buttonColors = ButtonColorsPrimary, + onClick = onSellClick, + ) + } + Box { + MoonActionIcon( + painter = painterResource(UIKitIcon.ic_ellipsis_16), + onClick = { moreMenuExpanded = !moreMenuExpanded }, + backgroundColor = UIKit.colorScheme.buttonTertiary.primaryBackground, + tintColor = UIKit.colorScheme.buttonTertiary.primaryForeground, + size = ButtonSizeLarge.height, + contentDescription = stringResource(Localization.more), + ) + ActionMenu( + offset = DpOffset(x = 0.dp, y = -ButtonSizeLarge.height), + width = 196.dp, + expanded = moreMenuExpanded, + onDismissRequest = { moreMenuExpanded = false }, + items = moreMenuItems, + onItemClick = { item -> + moreMenuExpanded = false + when (item.id) { + AssetDetailsMoreMenuSend -> onSendClick() + AssetDetailsMoreMenuReceive -> onReceiveClick() + else -> Unit + } + }, + ) + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesFeature.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesFeature.kt new file mode 100644 index 000000000..39023e97c --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesFeature.kt @@ -0,0 +1,76 @@ +package com.tonapps.trading.screens.shelves + +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.log.L +import com.tonapps.mvi.MviFeature +import com.tonapps.mvi.contract.MviAction +import com.tonapps.mvi.contract.MviState +import com.tonapps.mvi.contract.MviViewState +import com.tonapps.mvi.props.MviProperty +import com.tonapps.trading.TradeEntryTracker +import com.tonapps.trading.data.TradingRepository +import io.tradingapi.models.MarketItem +import io.tradingapi.models.ShelfGroup + +sealed interface ShelvesAction : MviAction { + data object Init : ShelvesAction + data object Retry : ShelvesAction +} + +sealed interface ShelvesState : MviState { + data object Loading : ShelvesState + data object Empty : ShelvesState + data class Data( + val shelfGroups: List, + ) : ShelvesState +} + +class ShelvesViewState( + val global: MviProperty, +) : MviViewState + +class ShelvesFeature( + private val tradingRepository: TradingRepository, +) : MviFeature( + initState = ShelvesState.Loading, + initAction = ShelvesAction.Init, +) { + + init { + AnalyticsHelper.Default.events.tradeUiFlow.tradeStarted(from = TradeEntryTracker.consumeFrom()) + } + + override fun createViewState(): ShelvesViewState { + return buildViewState { + ShelvesViewState(mviProperty { it }) + } + } + + override suspend fun executeAction(action: ShelvesAction) { + when (action) { + ShelvesAction.Init, ShelvesAction.Retry -> load() + } + } + + private suspend fun load() { + setState { ShelvesState.Loading } + try { + val shelfGroups = tradingRepository.getShelfGroups() + if (shelfGroups.isEmpty()) { + setState { ShelvesState.Empty } + } else { + setState { ShelvesState.Data(shelfGroups = shelfGroups) } + } + } catch (e: Throwable) { + L.e(e) + setState { ShelvesState.Empty } + } + } + + fun trackAssetClick(item: MarketItem) { + AnalyticsHelper.Default.events.tradeUiFlow.tradeClickAsset( + from = TradeEntryTracker.consumeFrom(), + asset = item.asset.id + ) + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesFragment.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesFragment.kt new file mode 100644 index 000000000..aec315fa0 --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesFragment.kt @@ -0,0 +1,47 @@ +package com.tonapps.trading.screens.shelves + +import android.os.Bundle +import android.view.View +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.tonapps.bus.generated.Events.AssetScreen.AssetScreenFrom +import com.tonapps.core.ComposableFragment +import com.tonapps.trading.AssetsFragment +import io.tradingapi.models.MarketListKey +import kotlinx.coroutines.flow.MutableStateFlow +import org.koin.androidx.compose.koinViewModel +import uikit.navigation.Navigation.Companion.navigation + +class ShelvesFragment : ComposableFragment() { + + private val scrollToShelfFlow = MutableStateFlow(null) + + fun scrollToShelf(key: String) { + val parsed = MarketListKey.decode(key) ?: return + scrollToShelfFlow.value = parsed + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setContent { + val viewModel = koinViewModel() + val scrollToShelfKey by scrollToShelfFlow.collectAsState() + ShelvesScreen( + feature = viewModel, + scrollToShelfKey = scrollToShelfKey, + onScrollToShelfHandled = { scrollToShelfFlow.value = null }, + onOpenAssets = { navigation?.add(AssetsFragment.newInstance(initialTab = it)) }, + onOpenSearch = { navigation?.add(AssetsFragment.newInstance(focusSearch = true)) }, + onOpenAssetDetails = { + navigation?.add( + AssetsFragment.newInstance( + marketItem = it, + from = AssetScreenFrom.TradeScreen, + ) + ) + }, + ) + } + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesScreen.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesScreen.kt new file mode 100644 index 000000000..d2e369b9e --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesScreen.kt @@ -0,0 +1,401 @@ +package com.tonapps.trading.screens.shelves + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.tonapps.icu.Formatter +import com.tonapps.mvi.props.observeSafeState +import com.tonapps.wallet.localization.Localization +import io.tradingapi.models.AssetRefSummary +import io.tradingapi.models.AssetType +import io.tradingapi.models.AssetsTab +import io.tradingapi.models.MarketItem +import io.tradingapi.models.MarketListKey +import io.tradingapi.models.MarketMetricsSummary +import io.tradingapi.models.ShelfConfig +import io.tradingapi.models.ShelfConfigSeeAll +import io.tradingapi.models.ShelfGroup +import io.tradingapi.models.ShelfType +import kotlinx.collections.immutable.toImmutableList +import ui.components.moon.MoonAsyncImage +import ui.components.moon.MoonContentTabs +import ui.components.moon.MoonTabItem +import ui.components.moon.cell.MoonSearchCell +import ui.components.moon.container.MoonScaffold +import ui.components.moon.screen.MoonEmptyScreen +import ui.preview.ThemedPreview +import ui.theme.Dimens +import ui.theme.Shapes +import ui.theme.UIKit + +@Composable +fun ShelvesScreen( + feature: ShelvesFeature, + onOpenAssets: (AssetsTab) -> Unit, + onOpenSearch: () -> Unit, + onOpenAssetDetails: (MarketItem) -> Unit, + scrollToShelfKey: MarketListKey? = null, + onScrollToShelfHandled: () -> Unit = {}, +) { + val state by feature.state.global.observeSafeState() + + ShelvesContent( + state = state, + onRetry = { feature.sendAction(ShelvesAction.Retry) }, + onOpenAssets = onOpenAssets, + onOpenSearch = onOpenSearch, + onOpenAssetDetails = { + feature.trackAssetClick(it) + onOpenAssetDetails(it) + }, + scrollToShelfKey = scrollToShelfKey, + onScrollToShelfHandled = onScrollToShelfHandled, + ) +} + +@Composable +private fun ShelvesContent( + state: ShelvesState, + onRetry: () -> Unit, + onOpenAssets: (AssetsTab) -> Unit, + onOpenSearch: () -> Unit, + onOpenAssetDetails: (MarketItem) -> Unit, + scrollToShelfKey: MarketListKey? = null, + onScrollToShelfHandled: () -> Unit = {}, +) { + MoonScaffold( + topBar = { + MoonSearchCell( + modifier = Modifier + .statusBarsPadding() + .padding(top = 16.dp, bottom = 8.dp), + placeholder = stringResource(Localization.search_by_ticker), + onClick = onOpenSearch + ) + } + ) { + when (state) { + ShelvesState.Loading -> ShelvesShimmer() + + ShelvesState.Empty -> MoonEmptyScreen( + text = stringResource(Localization.cant_find_anything), + buttonText = stringResource(Localization.retry), + onButtonClick = onRetry, + ) + + is ShelvesState.Data -> { + val listState = rememberLazyListState() + + LaunchedEffect(scrollToShelfKey, state.shelfGroups) { + val key = scrollToShelfKey ?: return@LaunchedEffect + val targetIndex = state.shelfGroups.indexOfFirst { group -> + group.items.any { it.key == key } + } + if (targetIndex >= 0) { + listState.animateScrollToItem(targetIndex) + } + onScrollToShelfHandled() + } + + LazyColumn( + state = listState, + modifier = Modifier + .navigationBarsPadding() + .padding(bottom = Dimens.heightBar), + ) { + items( + items = state.shelfGroups, + key = { group -> group.name }, + ) { group -> + ShelfGroupItem( + group = group, + initiallySelectedKey = scrollToShelfKey?.takeIf { + group.items.any { item -> item.key == it } + }, + onClickSeeAll = onOpenAssets, + onAssetClick = { onOpenAssetDetails(it) }, + ) + } + } + } + } + } +} + +@Composable +private fun ShelfGroupItem( + group: ShelfGroup, + onClickSeeAll: (AssetsTab) -> Unit, + onAssetClick: (MarketItem) -> Unit, + initiallySelectedKey: MarketListKey? = null, +) { + val tabs = group.items.map { MoonTabItem(id = it.key.ordinal, title = it.title) }.toImmutableList() + val seeAllRoute = group.items.firstOrNull { it.seeAll.enabled }?.seeAll?.route + + var selectedId by remember(group.items) { + val initialId = initiallySelectedKey?.ordinal + ?.takeIf { id -> tabs.any { it.id == id } } + ?: tabs.firstOrNull()?.id + ?: 0 + mutableIntStateOf(initialId) + } + + val items = group.items + .firstOrNull { it.key.ordinal == selectedId } + ?.items + .orEmpty() + + val showTabs = group.items.size > 1 + + ShelfContainer( + title = group.name, + seeAllEnabled = seeAllRoute != null, + onClickSeeAll = { seeAllRoute?.let { onClickSeeAll(it) } }, + ) { + if (showTabs) { + MoonContentTabs( + items = tabs, + selectedId = selectedId, + onSelect = { selectedId = it.id }, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + AssetsGrid(items = items, onAssetClick) + } +} + +@Composable +private fun ShelfContainer( + title: String, + seeAllEnabled: Boolean, + onClickSeeAll: () -> Unit, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(vertical = 12.dp), + text = title, + style = UIKit.typography.label1, + color = UIKit.colorScheme.text.primary, + maxLines = 1, + ) + if (seeAllEnabled) { + Text( + modifier = Modifier + .clip(Shapes.medium12) + .clickable(onClick = onClickSeeAll) + .padding(vertical = 12.dp), + text = stringResource(Localization.show_all), + style = UIKit.typography.body2, + color = UIKit.colorScheme.text.accent, + ) + } + } + Column( + modifier = Modifier + .background(shape = Shapes.medium, color = UIKit.colorScheme.background.content) + .padding(8.dp) + ) { + content() + } + } +} + +@Composable +private fun AssetsGrid( + items: List, + onAssetClick: (MarketItem) -> Unit, +) { + items.chunked(4).forEach { rowItems -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + ) { + rowItems.forEach { item -> + Box(modifier = Modifier.weight(1f)) { + AssetItem( + item = item, + onClick = { onAssetClick(item) }, + ) + } + } + repeat(4 - rowItems.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun AssetItem( + item: MarketItem, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(Shapes.medium12) + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MoonAsyncImage( + modifier = Modifier + .background(color = UIKit.colorScheme.background.contentTint, shape = CircleShape) + .size(56.dp) + .clip(CircleShape), + image = item.asset.imageUrl, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = item.asset.symbol, + style = UIKit.typography.body3, + color = UIKit.colorScheme.text.primary, + maxLines = 1, + ) + Spacer(modifier = Modifier.height(1.dp)) + Text( + text = Formatter.percent(item.metrics.change24hPercent), + style = UIKit.typography.body3, + color = when { + item.metrics.change24hPercent.startsWith("-") -> UIKit.colorScheme.accent.red + item.metrics.change24hPercent.startsWith("+") -> UIKit.colorScheme.accent.green + else -> UIKit.colorScheme.text.secondary + }, + maxLines = 1, + ) + } +} + +private fun mockItem(symbol: String, change: String) = + MarketItem( + asset = AssetRefSummary( + assetType = AssetType.asset, + id = "ton::default::$symbol", + symbol = symbol, + name = symbol, + decimals = 9, + imageUrl = "", + trustScore = 0, + isScam = false, + verification = AssetRefSummary.Verification.whitelist, + ), + metrics = MarketMetricsSummary( + price = "0.00001", + change24hPercent = change, + provider = "mock", + asOf = "", + ), + ) + +private val previewShelfGroups = listOf( + ShelfGroup( + name = "Top Movers", + items = listOf( + ShelfConfig( + key = MarketListKey.top_gainers, + title = "Top Gainers", + type = ShelfType.grid, + source = "mock", + seeAll = ShelfConfigSeeAll(enabled = true, route = AssetsTab.all), + items = listOf( + mockItem("SHREK", "18.43"), + mockItem("WZP", "-1.40"), + ), + ), + ShelfConfig( + key = MarketListKey.top_losers, + title = "Top Losers", + type = ShelfType.grid, + source = "mock", + seeAll = ShelfConfigSeeAll(enabled = true, route = AssetsTab.all), + items = listOf( + mockItem("B3", "+22.10"), + mockItem("VES", "+12.50"), + ), + ), + ), + ), + ShelfGroup( + name = "Most Traded", + items = listOf( + ShelfConfig( + key = MarketListKey.most_traded, + title = "Most Traded", + type = ShelfType.grid, + source = "mock", + seeAll = ShelfConfigSeeAll(enabled = true, route = AssetsTab.all), + items = listOf( + mockItem("TRUMPUS", "-4.50"), + mockItem("NOOTYA", "-2.30"), + ), + ), + ), + ), +) + +@Preview +@Composable +private fun ShelvesScreenPreview() { + ThemedPreview(isDarkOnly = true) { + ShelvesContent( + state = ShelvesState.Data(shelfGroups = previewShelfGroups), + onRetry = { }, + onOpenAssets = { }, + onOpenSearch = { }, + onOpenAssetDetails = { }, + ) + } +} + +@Preview +@Composable +private fun ShelvesScreenLoadingPreview() { + ThemedPreview(isDarkOnly = true) { + ShelvesContent( + state = ShelvesState.Loading, + onRetry = { }, + onOpenAssets = { }, + onOpenSearch = { }, + onOpenAssetDetails = { }, + ) + } +} diff --git a/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesShimmer.kt b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesShimmer.kt new file mode 100644 index 000000000..cf4a2fa7d --- /dev/null +++ b/apps/wallet/features/trading/src/main/kotlin/com/tonapps/trading/screens/shelves/ShelvesShimmer.kt @@ -0,0 +1,129 @@ +package com.tonapps.trading.screens.shelves + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ui.components.moon.MoonTextShimmer +import ui.theme.Dimens +import ui.theme.Shapes +import ui.theme.UIKit + +private const val TitlePlaceholder = "Top Movers" +private const val SeeAllPlaceholder = "Show all" +private const val SymbolPlaceholder = "TICKER" +private const val ChangePlaceholder = "+0.00%" + +@Composable +internal fun ShelvesShimmer( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .padding(bottom = Dimens.heightBar), + ) { + ShelfShimmerGroup(showSeeAll = true) + ShelfShimmerGroup(showSeeAll = false) + ShelfShimmerGroup(showSeeAll = false) + } +} + +@Composable +private fun ShelfShimmerGroup(showSeeAll: Boolean) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MoonTextShimmer( + text = TitlePlaceholder, + style = UIKit.typography.label1, + cornerRadius = 12.dp, + ) + if (showSeeAll) { + MoonTextShimmer( + text = SeeAllPlaceholder, + style = UIKit.typography.label1, + cornerRadius = 12.dp, + ) + } + } + + Column( + modifier = Modifier + .background(shape = Shapes.medium, color = UIKit.colorScheme.background.content) + .padding(8.dp), + ) { + repeat(2) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + ) { + repeat(4) { + Box(modifier = Modifier.weight(1f)) { + AssetShimmerItem() + } + } + } + } + } + } +} + +@Composable +private fun AssetShimmerItem() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .requiredSize(56.dp) + .background(UIKit.colorScheme.background.contentTint, CircleShape), + ) + Spacer(modifier = Modifier.height(8.dp)) + MoonTextShimmer( + text = SymbolPlaceholder, + style = UIKit.typography.body3, + cornerRadius = 8.dp, + backgroundFill = UIKit.colorScheme.background.contentTint, + highlightColor = UIKit.colorScheme.background.highlighted, + ) + Spacer(modifier = Modifier.height(1.dp)) + MoonTextShimmer( + text = ChangePlaceholder, + style = UIKit.typography.body3, + cornerRadius = 8.dp, + backgroundFill = UIKit.colorScheme.background.contentTint, + highlightColor = UIKit.colorScheme.background.highlighted, + ) + } +} diff --git a/apps/wallet/instance/app/.gitignore b/apps/wallet/instance/app/.gitignore index 987c4867b..ec227929c 100644 --- a/apps/wallet/instance/app/.gitignore +++ b/apps/wallet/instance/app/.gitignore @@ -2,4 +2,4 @@ /release /benchmarkRelease /src/release -/src/main/genereated \ No newline at end of file +/src/main/generated \ No newline at end of file diff --git a/apps/wallet/instance/app/build.gradle.kts b/apps/wallet/instance/app/build.gradle.kts index deb75d07f..48dfb554d 100644 --- a/apps/wallet/instance/app/build.gradle.kts +++ b/apps/wallet/instance/app/build.gradle.kts @@ -1,31 +1,23 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.android.kotlin) + id("target.android.compose") alias(libs.plugins.kotlin.compose) id("kotlin-parcelize") id("kotlinx-serialization") } android { - namespace = Build.namespacePrefix("tonkeeperx") - compileSdk = Build.compileSdkVersion - ndkVersion = Build.ndkVersion - - defaultConfig { - minSdk = Build.minSdkVersion - } + namespace = "com.tonapps.tonkeeperx" buildFeatures { buildConfig = true - compose = true } } dependencies { implementation(libs.koin.core) implementation(libs.koin.workmanager) - implementation(libs.kotlinX.datetime) - implementation(libs.kotlinX.collections.immutable) + implementation(libs.kotlinx.datetime) + implementation(libs.kotlinx.collections.immutable) implementation(libs.j2objc) implementation(libs.cbor) implementation(libs.ton.tvm) @@ -35,76 +27,92 @@ dependencies { implementation(libs.ton.tonapiTl) implementation(libs.ton.contract) - implementation(project(ProjectModules.KMP.ui)) - - implementation(project(ProjectModules.Wallet.localization)) - implementation(project(ProjectModules.Wallet.api)) - - implementation(project(ProjectModules.Wallet.Data.core)) - implementation(project(ProjectModules.Wallet.Data.tokens)) - implementation(project(ProjectModules.Wallet.Data.account)) - implementation(project(ProjectModules.Wallet.Data.settings)) - implementation(project(ProjectModules.Wallet.Data.rates)) - implementation(project(ProjectModules.Wallet.Data.collectibles)) - implementation(project(ProjectModules.Wallet.Data.events)) - implementation(project(ProjectModules.Wallet.Data.browser)) - implementation(project(ProjectModules.Wallet.Data.backup)) - implementation(project(ProjectModules.Wallet.Data.rn)) - implementation(project(ProjectModules.Wallet.Data.passcode)) - implementation(project(ProjectModules.Wallet.Data.staking)) - implementation(project(ProjectModules.Wallet.Data.purchase)) - implementation(project(ProjectModules.Wallet.Data.battery)) - implementation(project(ProjectModules.Wallet.Data.dapps)) - implementation(project(ProjectModules.Wallet.Data.contacts)) - implementation(project(ProjectModules.Wallet.Data.swap)) - implementation(project(ProjectModules.Wallet.Data.plugins)) - - implementation(project(ProjectModules.UIKit.core)) - implementation(project(ProjectModules.UIKit.flag)) - - implementation(libs.androidX.core) - implementation(libs.androidX.shortcuts) - implementation(libs.androidX.appCompat) - implementation(libs.androidX.activity) - implementation(libs.androidX.fragment) - implementation(libs.androidX.recyclerView) - implementation(libs.androidX.viewPager2) - implementation(libs.androidX.workManager) - implementation(libs.androidX.biometric) - implementation(libs.androidX.swiperefreshlayout) - implementation(libs.androidX.lifecycle) - implementation(libs.androidX.webkit) - implementation(libs.androidX.browser) - + implementation(projects.kmp.ui) + implementation(projects.kmp.mvi) + implementation(projects.kmp.async) + + implementation(projects.lib.bus) + + implementation(projects.apps.wallet.localization) + implementation(projects.apps.wallet.api) + + implementation(projects.apps.wallet.data.core) + implementation(projects.apps.wallet.data.tokens) + implementation(projects.apps.wallet.data.account) + implementation(projects.apps.wallet.data.settings) + implementation(projects.apps.wallet.data.rates) + implementation(projects.apps.wallet.data.collectibles) + implementation(projects.apps.wallet.data.events) + implementation(projects.apps.wallet.data.browser) + implementation(projects.apps.wallet.data.banner) + implementation(projects.apps.wallet.data.backup) + implementation(projects.apps.wallet.data.rn) + implementation(projects.apps.wallet.data.passcode) + implementation(projects.apps.wallet.data.staking) + implementation(projects.apps.wallet.data.purchase) + implementation(projects.apps.wallet.data.battery) + implementation(projects.apps.wallet.data.dapps) + implementation(projects.apps.wallet.data.tx) + implementation(projects.apps.wallet.data.contacts) + implementation(projects.apps.wallet.data.swap) + implementation(projects.apps.wallet.data.plugins) + implementation(projects.apps.wallet.data.legacy) + + implementation(projects.apps.wallet.features.core) + implementation(projects.apps.wallet.features.ramp) + implementation(projects.apps.wallet.data.features) + implementation(projects.lib.features) + implementation(projects.apps.wallet.features.dapp) + implementation(projects.apps.wallet.features.settings) + implementation(projects.apps.wallet.features.trading) + implementation(projects.apps.wallet.features.events) + + implementation(projects.ui.uikit.core) + implementation(projects.ui.uikit.flag) + + implementation(libs.androidx.core) + implementation(libs.androidx.shortcuts) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity) + implementation(libs.androidx.fragment) + implementation(libs.androidx.recyclerView) + implementation(libs.androidx.viewPager2) + implementation(libs.androidx.workManager) + implementation(libs.androidx.biometric) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.lifecycle) + implementation(libs.androidx.webkit) + implementation(libs.androidx.browser) implementation(libs.material) implementation(libs.flexbox) implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.analytics) implementation(libs.firebase.config) + implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) implementation(libs.firebase.messaging) implementation(libs.firebase.performance) - implementation(project(ProjectModules.Module.tonApi)) - implementation(project(ProjectModules.Module.blur)) - - implementation(project(ProjectModules.Lib.network)) - implementation(project(ProjectModules.Lib.icu)) - implementation(project(ProjectModules.Lib.qr)) - implementation(project(ProjectModules.Lib.emoji)) - implementation(project(ProjectModules.Lib.security)) - implementation(project(ProjectModules.Lib.blockchain)) - implementation(project(ProjectModules.Lib.extensions)) - implementation(project(ProjectModules.Lib.ledger)) - implementation(project(ProjectModules.Lib.ur)) - implementation(project(ProjectModules.Lib.base64)) - - implementation(libs.cameraX.base) - implementation(libs.cameraX.core) - implementation(libs.cameraX.lifecycle) - implementation(libs.cameraX.view) + implementation(projects.tonapi.legacy) + implementation(projects.ui.blur) + + implementation(projects.lib.network) + implementation(projects.lib.icu) + implementation(projects.lib.qr) + implementation(projects.lib.log) + implementation(projects.lib.emoji) + implementation(projects.lib.security) + implementation(projects.lib.blockchain) + implementation(projects.lib.extensions) + implementation(projects.lib.ledger) + implementation(projects.lib.ur) + implementation(projects.lib.base64) + + implementation(libs.camerax.base) + implementation(libs.camerax.core) + implementation(libs.camerax.lifecycle) + implementation(libs.camerax.view) implementation(libs.google.play.review) implementation(libs.google.play.billing) @@ -114,7 +122,9 @@ dependencies { implementation(libs.okhttp) implementation(libs.okhttp.sse) implementation(libs.aptabase) + implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) implementation(platform(libs.compose.bom)) implementation(libs.compose.foundation) @@ -128,6 +138,8 @@ dependencies { implementation(libs.compose.viewModel) + + implementation(projects.lib.walletKit) } tasks.withType().configureEach { @@ -139,4 +151,4 @@ tasks.withType().configureEach "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${buildDir}/compose_reports" ) } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/debug/AndroidManifest.xml b/apps/wallet/instance/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 9a8d15c04..000000000 --- a/apps/wallet/instance/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/AndroidManifest.xml b/apps/wallet/instance/app/src/main/AndroidManifest.xml index 7ac4c8aed..ab11446ed 100644 --- a/apps/wallet/instance/app/src/main/AndroidManifest.xml +++ b/apps/wallet/instance/app/src/main/AndroidManifest.xml @@ -1,244 +1,7 @@ - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/assets/key b/apps/wallet/instance/app/src/main/assets/key new file mode 100644 index 000000000..8b1f76553 Binary files /dev/null and b/apps/wallet/instance/app/src/main/assets/key differ diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/App.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/App.kt index a2977c654..f14951acf 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/App.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/App.kt @@ -2,101 +2,33 @@ package com.tonapps.tonkeeper import android.app.Application import android.content.res.Configuration -import android.graphics.Bitmap -import android.os.Build -import android.os.StrictMode -import android.util.Log -import androidx.camera.camera2.Camera2Config -import androidx.camera.core.CameraXConfig -import com.google.firebase.FirebaseApp -import com.tonapps.extensions.setLocales import com.tonapps.icu.CurrencyFormatter -import com.tonapps.tonkeeper.koin.koinModel -import com.tonapps.tonkeeper.koin.viewModelWalletModule -import com.tonapps.tonkeeper.koin.workerModule -import com.tonapps.tonkeeperx.BuildConfig -import com.tonapps.wallet.api.apiModule -import com.tonapps.wallet.data.account.accountModule -import com.tonapps.wallet.data.rates.ratesModule -import com.tonapps.wallet.data.token.tokenModule -import com.tonapps.wallet.data.swap.swapModule -import org.koin.android.ext.koin.androidContext -import org.koin.core.context.startKoin -import com.tonapps.wallet.data.backup.backupModule -import com.tonapps.wallet.data.battery.batteryModule -import com.tonapps.wallet.data.browser.browserModule -import com.tonapps.wallet.data.collectibles.collectiblesModule -import com.tonapps.wallet.data.plugins.pluginsModule -import com.tonapps.wallet.data.contacts.contactsModule import com.tonapps.wallet.data.core.Theme -import com.tonapps.wallet.data.core.dataModule -import com.tonapps.wallet.data.dapps.dAppsModule -import com.tonapps.wallet.data.events.eventsModule -import com.tonapps.wallet.data.passcode.passcodeModule -import com.tonapps.wallet.data.purchase.purchaseModule -import com.tonapps.wallet.data.rn.rnLegacyModule -import com.tonapps.wallet.data.settings.SettingsRepository -import com.tonapps.wallet.data.staking.stakingModule import com.tonapps.wallet.localization.Localization -import org.koin.core.component.KoinComponent -import org.koin.android.ext.android.inject -import org.koin.androidx.workmanager.koin.workManagerFactory -import java.util.concurrent.Executors +import uikit.R -class App: Application(), CameraXConfig.Provider, KoinComponent { +open class App : Application() { companion object { - + @Deprecated("Initialize object explicitly") lateinit var instance: App + @Deprecated("Migration to Compose") fun applyConfiguration(newConfig: Configuration) { CurrencyFormatter.onConfigurationChanged(newConfig) } } - private val settingsRepository: SettingsRepository by inject() - - override fun onCreate() { - if (BuildConfig.DEBUG && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - StrictMode.setVmPolicy(StrictMode.VmPolicy.Builder() - .penaltyLog() - .detectAll() - .penaltyListener(Executors.newSingleThreadExecutor()) { - Log.e("TonkeeperStrictModeLog", "StrictMode.VmPolicy: $it", it.cause) - }.build()) - } - - super.onCreate() - updateThemes() - - FirebaseApp.initializeApp(this) - - instance = this - - startKoin { - androidContext(this@App) - modules(koinModel, contactsModule, workerModule, dAppsModule, viewModelWalletModule, purchaseModule, batteryModule, stakingModule, passcodeModule, rnLegacyModule, swapModule, backupModule, dataModule, browserModule, apiModule, accountModule, ratesModule, tokenModule, eventsModule, collectiblesModule, pluginsModule) - workManagerFactory() - } - setLocales(settingsRepository.localeList) - } - fun updateThemes() { Theme.clear() - Theme.add("blue", uikit.R.style.Theme_App_Blue, title = getString(Localization.theme_deep_blue)) - Theme.add("dark", uikit.R.style.Theme_App_Dark, title = getString(Localization.theme_dark)) - Theme.add("light", uikit.R.style.Theme_App_Light, true, title = getString(Localization.theme_light)) + Theme.add("blue", R.style.Theme_App_Blue, title = getString(Localization.theme_deep_blue)) + Theme.add("dark", R.style.Theme_App_Dark, title = getString(Localization.theme_dark)) + Theme.add( + "light", + R.style.Theme_App_Light, + true, + title = getString(Localization.theme_light) + ) Theme.add("system", 0, title = getString(Localization.system)) } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - applyConfiguration(newConfig) - } - - override fun getCameraXConfig(): CameraXConfig { - return CameraXConfig.Builder - .fromConfig(Camera2Config.defaultConfig()) - .setMinimumLoggingLevel(Log.ERROR).build() - } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/AppConfig.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/AppConfig.kt index 355ef9e93..b1410559d 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/AppConfig.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/AppConfig.kt @@ -18,4 +18,4 @@ val Context.isLowDevice: Boolean } val Context.isBlurDisabled: Boolean - get() = !DevSettings.blurEnabled || (isLowDevice && 20 >= batteryLevel) \ No newline at end of file + get() = !DevSettings.blurEnabled || (isLowDevice && 20 >= batteryLevel) \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/Environment.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/Environment.kt index 964541a6f..7b9ec41d9 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/Environment.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/Environment.kt @@ -1,8 +1,10 @@ package com.tonapps.tonkeeper import android.content.Context +import androidx.compose.runtime.Composable import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability +import com.tonapps.core.helper.EnvironmentHelper import com.tonapps.tonkeeper.core.DevSettings import com.tonapps.tonkeeper.extensions.isDarkMode import com.tonapps.tonkeeper.os.AppInstall @@ -14,7 +16,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.mapNotNull import ui.theme.AppColorScheme -import ui.theme.UIKit import ui.theme.appColorSchemeBlue import ui.theme.appColorSchemeDark import ui.theme.appColorSchemeLight @@ -23,7 +24,7 @@ import java.util.Locale class Environment( private val context: Context, private val settingsRepository: SettingsRepository, -) { +) : EnvironmentHelper.Delegate { data class CountryData( val fromStore: String? = null, @@ -31,6 +32,8 @@ class Environment( val byNetwork: String? = null, val byIPAddress: String? = null, val byLocale: String? = null, + val timeZone: String? = null, + val isUsingVpn: Boolean = false, val debug: String? = null, ) { @@ -48,45 +51,35 @@ class Environment( val countryDataFlow = _countryDataFlow.asStateFlow() val countryFlow = _countryDataFlow.asStateFlow().mapNotNull { it.value }.distinctUntilChanged() - val country: String + val deviceCountry: String get() = _countryDataFlow.value.value ?: Locale.getDefault().country val storeCountry: String? get() = _countryDataFlow.value.storeCountry - init { - setDebugCountry(DevSettings.country) - setCountryBySimCard(DeviceCountry.fromSIM(context)) - setCountryByNetwork(DeviceCountry.fromNetwork(context)) - setCountryByLocale(DeviceCountry.fromLocale()) - } - - fun setDebugCountry(country: String?) { - if (BuildConfig.DEBUG) { - _countryDataFlow.value = _countryDataFlow.value.copy(debug = country?.uppercase()) - } - } + val simCountry: String? + get() = _countryDataFlow.value.bySimCard - fun setCountryFromStore(country: String?) { - _countryDataFlow.value = _countryDataFlow.value.copy(fromStore = country?.uppercase()) - } + val timezone: String? + get() = _countryDataFlow.value.timeZone - fun setCountryBySimCard(country: String?) { - _countryDataFlow.value = _countryDataFlow.value.copy(bySimCard = country?.uppercase()) - } + val vpnActive: Boolean + get() = _countryDataFlow.value.isUsingVpn - fun setCountryByNetwork(country: String?) { - _countryDataFlow.value = _countryDataFlow.value.copy(byNetwork = country?.uppercase()) - } - fun setCountryByIPAddress(country: String?) { - _countryDataFlow.value = _countryDataFlow.value.copy(byIPAddress = country?.uppercase()) - } + init { + setDebugCountry(DevSettings.country) - fun setCountryByLocale(country: String?) { - _countryDataFlow.value = _countryDataFlow.value.copy(byLocale = country?.uppercase()) + _countryDataFlow.value = _countryDataFlow.value.copy( + bySimCard = DeviceCountry.fromSIM(context)?.uppercase(), + byNetwork = DeviceCountry.fromNetwork(context)?.uppercase(), + byLocale = DeviceCountry.fromLocale()?.uppercase(), + timeZone = DeviceCountry.timeZone(), + isUsingVpn = DeviceCountry.isUsingVpn(context) + ) } + @get:Composable val theme: AppColorScheme get() { return when(settingsRepository.theme.key) { @@ -100,7 +93,7 @@ class Environment( val installerSource: AppInstall.Source by lazy { AppInstall.request(context) } val isFromGooglePlay: Boolean by lazy { - installerSource == AppInstall.Source.GOOGLE_PLAY + installerSource == AppInstall.Source.GOOGLE_PLAY || installerSource == AppInstall.Source.AURORA_STORE } val isGooglePlayServicesAvailable: Boolean by lazy { @@ -109,6 +102,48 @@ class Environment( resultCode == ConnectionResult.SUCCESS } + fun setDebugCountry(country: String?) { + if (BuildConfig.DEBUG) { + _countryDataFlow.value = _countryDataFlow.value.copy(debug = country?.uppercase()) + } + } + + fun setCountryFromStore(country: String?) { + _countryDataFlow.value = _countryDataFlow.value.copy(fromStore = country?.uppercase()) + } + + fun setCountryByIPAddress(country: String?) { + _countryDataFlow.value = _countryDataFlow.value.copy(byIPAddress = country?.uppercase()) + } + + override fun deviceCountry(): String { + return deviceCountry + } + + override fun storeCountry(): String? { + return storeCountry + } + + override fun simCountry(): String? { + return simCountry + } + + override fun timezone(): String? { + return timezone + } + + override fun isVpnActive(): Boolean { + return vpnActive + } + + override fun locale(): String { + return settingsRepository.getLocale().language + } + + override fun currency(): String { + return settingsRepository.currency.code + } + private companion object { private fun fixCountryCode(country: String): String { val fixedCountry = if (country.length == 2) country.uppercase() else "AE" diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/RemoteConfig.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/RemoteConfig.kt deleted file mode 100644 index 6fc1a0cef..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/RemoteConfig.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.tonapps.tonkeeper - -import android.content.Context -import android.util.Log -import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings -import com.tonapps.wallet.api.API - -class RemoteConfig(context: Context, private val api: API) { - private val remoteConfig: FirebaseRemoteConfig = FirebaseRemoteConfig.getInstance() - - private enum class FeatureFlag(val key: String) { - IS_DAPPS_DISABLE("isDappsDisable"), - HARDCODED_COUNTRY_CODE("hardcodedCountryCode"), - IN_APP_UPDATE_AVAILABLE("inAppUpdateAvailable"), - IS_COUNTRY_PICKER_DISABLE("isCountryPickerDisable"), - NATIVE_ONRAMP_ENABLED("native_onrmap_enabled"), - ONBOARDING_STORIES_ENABLED("onboarding_stories_enabled"); - } - - // - init { - val configSettings = FirebaseRemoteConfigSettings.Builder() - .setMinimumFetchIntervalInSeconds(0) - .build() - remoteConfig.setConfigSettingsAsync(configSettings) - - val defaults = mapOf( - FeatureFlag.IS_DAPPS_DISABLE.key to true, - FeatureFlag.ONBOARDING_STORIES_ENABLED.key to true - ) - - remoteConfig.setDefaultsAsync(defaults) - } - - fun fetchAndActivate() { - remoteConfig.fetchAndActivate().addOnCompleteListener { task -> - if (task.isSuccessful) { - Log.d("RemoteConfig", "Fetched and activated successfully") - } else { - Log.e("RemoteConfig", "Fetch failed, using defaults") - } - } - } - - val inAppUpdateAvailable: Boolean - get() = remoteConfig.getBoolean(FeatureFlag.IN_APP_UPDATE_AVAILABLE.key) - - val nativeOnrmapEnabled: Boolean - get() = remoteConfig.getBoolean(FeatureFlag.NATIVE_ONRAMP_ENABLED.key) - - val isCountryPickerDisable: Boolean - get() = remoteConfig.getBoolean(FeatureFlag.IS_COUNTRY_PICKER_DISABLE.key) - - val hardcodedCountryCode: String? - get() = remoteConfig.getString(FeatureFlag.HARDCODED_COUNTRY_CODE.key).takeIf { it.isNotEmpty() } - - val isDappsDisable: Boolean - get() = remoteConfig.getBoolean(FeatureFlag.IS_DAPPS_DISABLE.key) - - val isOnboardingStoriesEnabled: Boolean - get() = remoteConfig.getBoolean(FeatureFlag.ONBOARDING_STORIES_ENABLED.key) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/api/AppApiModule.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/api/AppApiModule.kt new file mode 100644 index 000000000..730b301a0 --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/api/AppApiModule.kt @@ -0,0 +1,16 @@ +package com.tonapps.tonkeeper.api + +import com.tonapps.network.interceptor.LoggingInterceptor +import com.tonapps.tonkeeper.core.DevSettings +import com.tonapps.tonkeeperx.BuildConfig +import org.koin.dsl.module + +val appApiModule = module { + single(createdAtStart = true) { + object : LoggingInterceptor.Delegate { + override fun isEnabled(): Boolean { + return DevSettings.isLogsEnabled || BuildConfig.DEBUG + } + } + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/billing/BillingManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/billing/BillingManager.kt index 862d7fca4..91baae97f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/billing/BillingManager.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/billing/BillingManager.kt @@ -2,7 +2,6 @@ package com.tonapps.tonkeeper.billing import android.app.Activity import android.content.Context -import android.util.Log import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingFlowParams @@ -19,22 +18,21 @@ import com.android.billingclient.api.consumePurchase import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.extensions.MutableEffectFlow import com.tonapps.extensions.filterList +import com.tonapps.log.L import com.tonapps.tonkeeper.Environment -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -48,7 +46,13 @@ class BillingManager( private var billingClient: BillingClient = BillingClient.newBuilder(context) .setListener(this) - .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().enablePrepaidPlans().build()) + .enablePendingPurchases( + PendingPurchasesParams + .newBuilder() + .enableOneTimeProducts() + .enablePrepaidPlans() + .build() + ) .build() private val _productsFlow = MutableStateFlow?>(null) @@ -80,6 +84,7 @@ class BillingManager( try { getProducts(productIds, productType) } catch (e: Throwable) { + L.e(e) null } } ?: emptyList() @@ -108,6 +113,7 @@ class BillingManager( if (productIds.isEmpty()) { return@ready emptyList() } + try { val productList = productIds.map { productId -> QueryProductDetailsParams.Product.newBuilder() @@ -130,10 +136,10 @@ class BillingManager( private fun log(msg: String, e: Throwable? = null) { if (e == null) { - Log.d("BillingManager", msg) + L.d("BillingManager", msg) } else { - Log.e("BillingManager", e.message ?: msg) - Log.e("BillingManager", msg, e) + L.e("BillingManager", e.message ?: msg) + L.e("BillingManager", msg, e) FirebaseCrashlytics.getInstance().recordException(e) } } @@ -194,9 +200,11 @@ class BillingManager( } @OptIn(FlowPreview::class) - fun productFlow(productId: String) = productsFlow.take(1).filterList { product -> - product.productId == productId - }.mapNotNull { it.firstOrNull() }.timeout(5.seconds) + fun productFlow(productId: String) = productsFlow + .take(1) + .filterList { product -> product.productId == productId } + .mapNotNull { it.firstOrNull() } + .timeout(5.seconds) suspend fun restorePurchases(): List = billingClient.ready { getPendingPurchases(it) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/client/safemode/SafeModeClient.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/client/safemode/SafeModeClient.kt index 6e3b9d888..40e332594 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/client/safemode/SafeModeClient.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/client/safemode/SafeModeClient.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.client.safemode import android.content.Context import android.net.Uri -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.MutableEffectFlow import com.tonapps.icu.Coins import com.tonapps.tonkeeper.client.safemode.BadDomainsEntity.Companion.isNullOrEmpty diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/Amount.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/Amount.kt deleted file mode 100644 index 8badcc3d8..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/Amount.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tonapps.tonkeeper.core - -import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.TokenEntity - -data class Amount( - val value: Coins = Coins.ZERO, - val token: TokenEntity = TokenEntity.TON -) { - - val isTon: Boolean - get() = token.isTon - - val symbol: String - get() = token.symbol - - val decimals: Int - get() = token.decimals -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/AnalyticsHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/AnalyticsHelper.kt deleted file mode 100644 index ecc85dc68..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/AnalyticsHelper.kt +++ /dev/null @@ -1,411 +0,0 @@ -package com.tonapps.tonkeeper.core - -import android.content.Context -import android.net.Uri -import android.util.Log -import androidx.annotation.UiThread -import com.aptabase.Aptabase -import com.aptabase.InitOptions -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.tonapps.blockchain.ton.TonAddressTags -import com.tonapps.extensions.hostOrNull -import com.tonapps.extensions.toUriOrNull -import com.tonapps.wallet.api.entity.ConfigEntity -import com.tonapps.wallet.api.entity.StoryEntity -import com.tonapps.wallet.data.settings.SettingsRepository -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.collections.set - -class AnalyticsHelper( - private val settingsRepository: SettingsRepository -) { - - private data class QueuedEvent( - val eventName: String, - val props: Map - ) - - private val installId: String - get() = settingsRepository.installId - - private val isInitialized = AtomicBoolean(false) - private val eventQueue = ConcurrentLinkedQueue() - private val regexPrivateData: Regex by lazy { - Regex("[a-f0-9]{64}|0:[a-f0-9]{64}") - } - - private fun getAddressType(address: String): String { - if (address.startsWith("0:")) { - return "raw" - } - val tags = TonAddressTags.of(address) - return if (tags.isBounceable) "bounce" else "non-bounce" - } - - @UiThread - fun simpleTrackEvent( - eventName: String, - props: MutableMap = hashMapOf() - ) { - - trackEvent(eventName, props) - } - - private fun trackEvent(eventName: String, props: Map = hashMapOf()) { - if (isInitialized.get()) { - send(eventName, props) - } else { - eventQueue.offer(QueuedEvent(eventName, props)) - } - } - - private fun send(eventName: String, props: Map = hashMapOf()) { - val fixedProps = props.mapValues { - if (it is Uri) { - it.value.toString() - } else { - it.value - } - }.toMutableMap() - fixedProps["firebase_user_id"] = installId - Aptabase.instance.trackEvent(eventName, fixedProps) - } - - @UiThread - fun simpleTrackScreenEvent(eventName: String, from: String) { - simpleTrackEvent( - eventName, hashMapOf( - "from" to from - ) - ) - } - - fun setConfig(context: Context, config: ConfigEntity) { - initAptabase( - context = context, - appKey = config.aptabaseAppKey, - host = config.aptabaseEndpoint - ) - } - - private fun removePrivateDataFromUrl(url: String): String { - return url.replace(regexPrivateData, "X") - } - - @UiThread - fun tcRequest(url: String) { - val props = hashMapOf( - "dapp_url" to url - ) - trackEvent("tc_request", props) - } - - @UiThread - fun swapOpen(uri: Uri, native: Boolean) { - val props = hashMapOf( - "provider_name" to (uri.host ?: "unknown"), - "provider_domain" to uri - ) - if (native) { - props["type"] = "native" - } else { - props["type"] = "old" - } - trackEvent("swap_open", props) - } - - @UiThread - fun swapClick( - jettonSymbolFrom: String, - jettonSymbolTo: String, - native: Boolean, - providerName: String, - ) { - val props = hashMapOf( - "jetton_symbol_from" to jettonSymbolFrom, - "jetton_symbol_to" to jettonSymbolTo, - "type" to if (native) "native" else "old", - "provider_name" to providerName - ) - trackEvent("swap_click", props) - } - - @UiThread - fun swapConfirm( - jettonSymbolFrom: String, - jettonSymbolTo: String, - providerName: String, - providerUrl: String, - native: Boolean - ) { - val props = hashMapOf( - "jetton_symbol_from" to jettonSymbolFrom, - "jetton_symbol_to" to jettonSymbolTo, - "type" to if (native) "native" else "old", - "provider_name" to providerName, - "provider_domain" to (providerUrl.toUriOrNull()?.hostOrNull ?: providerUrl) - ) - trackEvent("swap_confirm", props) - } - - @UiThread - fun swapSuccess( - jettonSymbolFrom: String, - jettonSymbolTo: String, - providerName: String, - providerUrl: String, - native: Boolean - ) { - val props = hashMapOf( - "jetton_symbol_from" to jettonSymbolFrom, - "jetton_symbol_to" to jettonSymbolTo, - "type" to if (native) "native" else "old", - "provider_name" to providerName, - "provider_domain" to (providerUrl.toUriOrNull()?.hostOrNull ?: providerUrl) - ) - trackEvent("swap_success", props) - } - - @UiThread - fun dappSharingCopy(name: String, from: String, url: String) { - val props = hashMapOf( - "name" to name, - "from" to from, - "url" to url - ) - trackEvent("dapp_sharing_copy", props) - } - - @UiThread - fun tcConnect(url: String, pushEnabled: Boolean) { - val props = hashMapOf( - "dapp_url" to url, - "allow_notifications" to pushEnabled - ) - trackEvent("tc_connect", props) - } - - @UiThread - fun tcViewConfirm(url: String, address: String) { - val props = hashMapOf( - "dapp_url" to url, - "address_type" to getAddressType(address) - ) - trackEvent("tc_view_confirm", props) - } - - @UiThread - fun tcSendSuccess(url: String, address: String, feePaid: String) { - val props = hashMapOf( - "dapp_url" to url, - "address_type" to getAddressType(address), - "network_fee_paid" to feePaid - ) - trackEvent("tc_send_success", props) - } - - @UiThread - fun firstLaunch(referrer: String?, deeplink: String?) { - val props = emptyMap().toMutableMap() - referrer?.let { - props["referrer"] = it - } - deeplink?.let { - props["deeplink"] = it - } - trackEvent("first_launch", props) - } - - @UiThread - fun openRefDeeplink(deeplink: String) { - val props = hashMapOf( - "deeplink" to deeplink - ) - trackEvent("ads_deeplink", props) - } - - @UiThread - fun batterySuccess( - type: String, - promo: String, - token: String, - size: String? = null - ) { - simpleTrackEvent( - "battery_success", hashMapOf( - "type" to type, - "promo" to promo, - "jetton" to token, - "size" to (size ?: "null") - ) - ) - } - - @UiThread - fun onRampOpen(source: String) { - simpleTrackScreenEvent("onramp_open", source) - } - - @UiThread - fun onRampEnterAmount( - type: String, - sellAsset: String, - buyAsset: String, - countryCode: String - ) { - val props = hashMapOf( - "type" to type, - "sell_asset" to sellAsset, - "buy_asset" to buyAsset, - "country_code" to countryCode - ) - trackEvent("onramp_enter_amount", props) - } - - @UiThread - fun onRampOpenWebview( - type: String, - sellAsset: String, - buyAsset: String, - countryCode: String, - paymentMethod: String?, - providerName: String, - providerDomain: String - ) { - - fun fixPaymentMethodName(value: String): String { - return when (value) { - "card" -> "Credit Card" - "google_pay" -> "Google Pay" - "paypal" -> "PayPal" - "revolut" -> "Revolut" - else -> value - } - } - - val props = hashMapOf( - "type" to type, - "sell_asset" to sellAsset, - "buy_asset" to buyAsset, - "country_code" to countryCode, - "payment_method" to (paymentMethod?.let(::fixPaymentMethodName) ?: "unknown"), - "provider_name" to providerName, - "provider_domain" to providerDomain - ) - trackEvent("onramp_continue_to_provider", props) - } - - @UiThread - fun onRampClick( - type: String, - placement: String, - location: String, - name: String, - url: String - ) { - trackEvent( - "onramp_click", hashMapOf( - "type" to type, - "placement" to placement, - "location" to location, - "name" to name, - "url" to url - ) - ) - } - - @UiThread - fun trackPushClick(pushId: String, payload: String) { - trackEvent( - "push_click", hashMapOf( - "push_id" to pushId, - "payload" to removePrivateDataFromUrl(payload) - ) - ) - } - - @UiThread - fun trackStoryClick( - storiesId: String, - button: StoryEntity.Button, - index: Int - ) { - trackEvent( - "story_click", hashMapOf( - "story_id" to storiesId, - "button_title" to button.title, - "button_type" to button.type, - "button_payload" to button.payload, - "page_number" to index + 1 - ) - ) - } - - @UiThread - fun trackStoryView(storiesId: String, index: Int) { - trackEvent( - "story_page_view", hashMapOf( - "story_id" to storiesId, - "page_number" to index - ) - ) - } - - @UiThread - fun trackStoryOpen(storiesId: String, from: String) { - trackEvent( - "story_open", hashMapOf( - "story_id" to storiesId, - "from" to from - ) - ) - } - - @UiThread - fun trackEventClickDApp( - url: String, - name: String, - source: String, - country: String, - ) { - trackEvent( - "click_dapp", hashMapOf( - "url" to url, - "name" to name, - "from" to source, - "location" to country - ) - ) - } - - private fun processEventQueue() { - if (!isInitialized.get()) { - return - } - while (eventQueue.isNotEmpty()) { - val queuedEvent = eventQueue.poll() - if (queuedEvent != null) { - send(queuedEvent.eventName, queuedEvent.props) - } - } - } - - private fun initAptabase( - context: Context, - appKey: String, - host: String - ) { - try { - val options = InitOptions( - host = host - ) - Aptabase.instance.initialize(context, appKey, options) - if (isInitialized.compareAndSet(false, true)) { - processEventQueue() - } - } catch (e: Throwable) { - FirebaseCrashlytics.getInstance().recordException(e) - } - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/DevSettings.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/DevSettings.kt index ffb1607d8..e37c58cc8 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/DevSettings.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/DevSettings.kt @@ -1,15 +1,43 @@ package com.tonapps.tonkeeper.core -import android.util.Log -import com.tonapps.tonkeeper.App -import androidx.core.content.edit +import android.annotation.SuppressLint +import android.content.Context +import com.tonapps.log.L import com.tonapps.extensions.putBoolean import com.tonapps.extensions.putLong import com.tonapps.extensions.putString +import com.tonapps.tonkeeper.App +import com.tonapps.tonkeeperx.BuildConfig +@Deprecated( + message = "We're migrating to DataStore from SharedPreferences", + replaceWith = ReplaceWith("com.tonapps.core.flags.LocalConfig"), +) object DevSettings { - private val prefs = App.instance.getSharedPreferences("dev_settings", 0) + private val prefs by lazy { App.instance.getSharedPreferences("dev_settings", 0) } + + fun checkInstallationVersion(version: Long) { + if (installationVersion == 0L) { + installationVersion = version + } + } + + var installationVersion: Long = prefs.getLong("install_version", 0) + set(value) { + if (field != value) { + field = value + prefs.putLong("install_version", value) + } + } + + var tetraEnabled: Boolean = prefs.getBoolean("tetra_enabled", false) + set(value) { + if (field != value) { + field = value + prefs.putBoolean("tetra_enabled", value) + } + } var country: String? = prefs.getString("country", null) set(value) { @@ -59,6 +87,22 @@ object DevSettings { } } + var isLogsEnabled: Boolean = prefs.getBoolean("is_logs_enabled", false) + set(value) { + if (field != value) { + field = value + prefs.putBoolean("is_logs_enabled", value) + } + } + + var webViewDataDir: String? = prefs.getString("webview_data_dir", null) + set(value) { + if (field != value) { + field = value + prefs.putString("webview_data_dir", value) + } + } + var ignoreSystemFontSize: Boolean = prefs.getBoolean("ignore_system_font_size", false) set(value) { if (field != value) { @@ -69,13 +113,21 @@ object DevSettings { fun tonConnectLog(message: String, error: Boolean = false) { - if (tonConnectLogs) { + if (tonConnectLogs || BuildConfig.DEBUG) { if (error) { - Log.e("TonConnect", message) + L.e("TonConnect", message) } else { - Log.d("TonConnect", message) + L.d("TonConnect", message) } } } -} \ No newline at end of file + // Migrations + var isWebviewFolderMigrated: Boolean = prefs.getBoolean("is_webview_folder_migrated", false) + set(value) { + if (field != value) { + field = value + prefs.putBoolean("is_webview_folder_migrated", value) + } + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/Fee.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/Fee.kt deleted file mode 100644 index 0a378c1c1..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/Fee.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tonapps.tonkeeper.core - -import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.TokenEntity -import kotlin.math.abs - -data class Fee( - val value: Coins, - val isRefund: Boolean, - val token: TokenEntity = TokenEntity.TON -) { - - val fee: Coins - get() = if (isRefund) Coins.ZERO else value - - val refund: Coins - get() = if (isRefund) value else Coins.ZERO - - constructor(value: Long, isRefund: Boolean) : this( - value = Coins.of(value), - isRefund = isRefund, - ) - - constructor(value: Long) : this( - value = abs(value), - isRefund = value > 0 - ) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/FirebaseHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/FirebaseHelper.kt index 8541ff290..3372026f6 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/FirebaseHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/FirebaseHelper.kt @@ -30,4 +30,17 @@ object FirebaseHelper { param("emoji", emoji) } } + + fun manifestFetchFailed(kind: String, url: String?) { + Firebase.analytics.logEvent("manifest_fetch_failed") { + param("kind", kind) + if (url != null) param("url", url) + } + } + + fun manifestFetchRetry(url: String?) { + Firebase.analytics.logEvent("manifest_fetch_retry") { + if (url != null) param("url", url) + } + } } \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/InsufficientFundsException.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/InsufficientFundsException.kt deleted file mode 100644 index 1858dfb2f..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/InsufficientFundsException.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tonapps.tonkeeper.core - -import com.tonapps.icu.Coins -import com.tonapps.tonkeeper.ui.screen.send.main.helper.InsufficientBalanceType -import com.tonapps.wallet.data.core.currency.WalletCurrency - -class InsufficientFundsException( - val currency: WalletCurrency, - val required: Coins, - val available: Coins, - val type: InsufficientBalanceType, - val withRechargeBattery: Boolean, - val singleWallet: Boolean -) : Exception("Insufficient funds: required $required, available $available, currency $currency") { - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/SendBlockchainException.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/SendBlockchainException.kt deleted file mode 100644 index 4a23fb57c..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/SendBlockchainException.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tonapps.tonkeeper.core - -import androidx.annotation.StringRes -import com.tonapps.extensions.ErrorForUserException -import com.tonapps.wallet.api.SendBlockchainState -import com.tonapps.wallet.localization.Localization - -sealed class SendBlockchainException( - @StringRes stringRes: Int, - cause: Throwable? = null -): ErrorForUserException(stringRes = stringRes, cause = cause) { - - companion object { - - fun fromState(state: SendBlockchainState): SendBlockchainException { - return when (state) { - SendBlockchainState.STATUS_ERROR -> SendBlockchainStatusException - SendBlockchainState.UNKNOWN_ERROR -> SendBlockchainErrorException - else -> SendBlockchainErrorException - } - } - } - - data object SendBlockchainStatusException: SendBlockchainException(Localization.sending_error) { - private fun readResolve(): Any = SendBlockchainStatusException - } - - data object SendBlockchainErrorException: SendBlockchainException(Localization.error) { - private fun readResolve(): Any = SendBlockchainStatusException - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/assetsHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/assetsHelper.kt new file mode 100644 index 000000000..03cac60b8 --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/assetsHelper.kt @@ -0,0 +1,35 @@ +package com.tonapps.tonkeeper.core + +import com.tonapps.icu.Coins +import com.tonapps.icu.Coins.Companion.sumOf +import com.tonapps.legacy.enteties.AssetsEntity +import com.tonapps.legacy.enteties.AssetsEntity.Token +import com.tonapps.legacy.enteties.AssetsExtendedEntity +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.wallet.data.settings.SettingsRepository +import com.tonapps.wallet.data.settings.entities.TokenPrefsEntity + +// TODO remove +suspend fun List.sort( + wallet: WalletEntity, + settingsRepository: SettingsRepository +): List { + return map { asset -> + val pref = if (asset is Token) { + settingsRepository.getTokenPrefs(wallet.id, asset.token.address, asset.token.blacklist) + } else { + TokenPrefsEntity() + } + AssetsExtendedEntity(asset, pref, wallet.accountId) + }.filter { !it.hidden }.sortedWith(AssetsExtendedEntity.comparator).map { it.raw } +} + +fun List.sumOfVerifiedFiat(): Coins { + return sumOf { + if (it !is Token || it.token.verified) { + it.fiat + } else { + Coins.ZERO + } + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AmountEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AmountEntity.kt deleted file mode 100644 index 923b854e8..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AmountEntity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tonapps.tonkeeper.core.entities - -import com.tonapps.icu.Coins - -data class AmountEntity( - val value: Value, - val converted: Value -) { - - data class Value( - val value: Coins, - val format: CharSequence - ) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AssetsEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AssetsEntity.kt deleted file mode 100644 index 07cbf161e..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/AssetsEntity.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.tonapps.tonkeeper.core.entities - -import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency -import com.tonapps.wallet.data.settings.SettingsRepository -import com.tonapps.wallet.data.settings.entities.TokenPrefsEntity -import com.tonapps.wallet.data.token.entities.AccountTokenEntity - -sealed class AssetsEntity( - val fiat: Coins, -) { - - companion object { - - suspend fun List.sort( - wallet: WalletEntity, - settingsRepository: SettingsRepository - ): List { - return map { asset -> - val pref = if (asset is Token) { - settingsRepository.getTokenPrefs(wallet.id, asset.token.address, asset.token.blacklist) - } else { - TokenPrefsEntity() - } - AssetsExtendedEntity(asset, pref, wallet.accountId) - }.filter { !it.hidden }.sortedWith(AssetsExtendedEntity.comparator).map { it.raw } - } - } - - data class Staked(val staked: StakedEntity): AssetsEntity(staked.fiatBalance) { - - val isTonstakers: Boolean - get() = staked.isTonstakers - - val liquidToken: BalanceEntity? - get() = staked.liquidToken - - val readyWithdraw: Coins - get() = staked.readyWithdraw - } - - data class Token( - val token: AccountTokenEntity - ): AssetsEntity(token.fiat) { - - val address: String - get() = token.address - - val decimals: Int - get() = token.decimals - - val balance: Coins - get() = token.balance.value - - val symbol: String - get() = token.symbol - - val blockchain: Blockchain - get() = token.balance.token.blockchain - - constructor(token: TokenEntity): this( - AccountTokenEntity.createEmpty(token, "") - ) - } - - data class Currency( - val currency: WalletCurrency, - val coins: Coins = Coins.ZERO - ): AssetsEntity(coins) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/SendMetadataEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/SendMetadataEntity.kt deleted file mode 100644 index 462f8afa4..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/SendMetadataEntity.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tonapps.tonkeeper.core.entities - - -data class SendMetadataEntity( - val seqno: Int, - val validUntil: Long, -) \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/SimpleCurrencyEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/SimpleCurrencyEntity.kt deleted file mode 100644 index 8cdd47918..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/SimpleCurrencyEntity.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tonapps.tonkeeper.core.entities - -import com.tonapps.icu.Coins -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency -import com.tonapps.wallet.data.token.entities.AccountTokenEntity - -data class SimpleCurrencyEntity( - val code: String, - val address: String, - val decimals: Int, -) { - - constructor(currency: WalletCurrency) : this( - currency.code, - currency.code, - currency.decimals - ) - - constructor(token: TokenEntity) : this( - token.symbol, - token.address, - token.decimals - ) - - constructor(token: BalanceEntity) : this(token.token) - - constructor(token: AccountTokenEntity) : this(token.balance) - - fun coins(value: Double) = Coins.of(value, decimals) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/StakedEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/StakedEntity.kt deleted file mode 100644 index 59969a5da..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/StakedEntity.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.tonapps.tonkeeper.core.entities - -import com.tonapps.blockchain.ton.extensions.equalsAddress -import com.tonapps.icu.Coins -import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.BalanceEntity -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency -import com.tonapps.wallet.data.rates.RatesRepository -import com.tonapps.wallet.data.staking.StakingPool -import com.tonapps.wallet.data.staking.entities.PoolEntity -import com.tonapps.wallet.data.staking.entities.StakingEntity -import com.tonapps.wallet.data.token.entities.AccountTokenEntity - -data class StakedEntity( - val pool: PoolEntity, - val balance: Coins, - val readyWithdraw: Coins, - val fiatBalance: Coins, - val fiatReadyWithdraw: Coins, - val liquidToken: BalanceEntity? = null, - val pendingDeposit: Coins, - val pendingWithdraw: Coins, - val cycleStart: Long, - val cycleEnd: Long, -) { - - val isTonstakers: Boolean - get() = pool.isTonstakers - - val maxApy: Boolean - get() = pool.maxApy - - companion object { - - suspend fun create( - wallet: WalletEntity, - staking: StakingEntity, - tokens: List, - currency: WalletCurrency, - ratesRepository: RatesRepository, - api: API, - ): List { - val fiatRates = ratesRepository.getTONRates(currency) - val list = mutableListOf() - val activePools = getActivePools(staking, tokens) - for (pool in activePools) { - if (pool.implementation == StakingPool.Implementation.LiquidTF) { - val isTonstakersAlready = list.any { it.isTonstakers } - if (isTonstakersAlready) { - continue - } - - val liquidJettonMaster = pool.liquidJettonMaster ?: continue - val token = tokens.find { it.address.equalsAddress(liquidJettonMaster) } ?: continue - val rates = ratesRepository.getRates(WalletCurrency.TON, token.address) - val balance = rates.convert(token.address, token.balance.value) - val readyWithdraw = rates.convert(token.address, staking.getReadyWithdraw(pool)) - val pendingDeposit = rates.convert(token.address, staking.getPendingDeposit(pool)) - val pendingWithdraw = rates.convert(token.address, staking.getPendingWithdraw(pool)) - list.add(StakedEntity( - pool = pool, - balance = balance, - fiatBalance = fiatRates.convertTON(balance), - readyWithdraw = readyWithdraw, - fiatReadyWithdraw = fiatRates.convertTON(readyWithdraw), - liquidToken = token.balance.copy(), - pendingDeposit = pendingDeposit, - pendingWithdraw = pendingWithdraw, - cycleStart = pool.cycleStart, - cycleEnd = pool.cycleEnd, - )) - } else { - val balance = staking.getAmount(pool) - val readyWithdraw = staking.getReadyWithdraw(pool) - val pendingDeposit = staking.getPendingDeposit(pool) - - list.add(StakedEntity( - pool = pool, - balance = balance, - fiatBalance = fiatRates.convertTON(balance), - readyWithdraw = readyWithdraw, - fiatReadyWithdraw = fiatRates.convertTON(readyWithdraw), - pendingDeposit = pendingDeposit, - pendingWithdraw = staking.getPendingWithdraw(pool), - cycleStart = pool.cycleStart, - cycleEnd = pool.cycleEnd, - )) - } - } - return list - } - - private fun getActivePools( - staking: StakingEntity, - tokens: List - ): List { - val pools = mutableListOf() - for (token in tokens.filter { staking.poolsJettonAddresses.contains(it.address) }) { - staking.findPoolByTokenAddress(token.address)?.let { pools.add(it) } - } - - for (info in staking.info) { - staking.findPoolByAddress(info.pool)?.let { pools.add(it) } - } - return pools - } - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/WalletExtendedEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/WalletExtendedEntity.kt deleted file mode 100644 index 5ee9f785a..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/entities/WalletExtendedEntity.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tonapps.tonkeeper.core.entities - -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.settings.entities.WalletPrefsEntity - -data class WalletExtendedEntity( - val raw: WalletEntity, - val prefs: WalletPrefsEntity, -) { - - val index: Int - get() = prefs.index -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/Extensions.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/Extensions.kt index 506d38bef..242202163 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/Extensions.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/Extensions.kt @@ -1,25 +1,21 @@ package com.tonapps.tonkeeper.core.history -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.icu.Coins -import com.tonapps.wallet.localization.Localization -import com.tonapps.tonkeeperx.R -import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency -import com.tonapps.wallet.data.events.ActionType +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.wallet.api.extensions.toTokenEntity import com.tonapps.wallet.data.rates.RatesRepository import io.tonapi.models.Action import io.tonapi.models.JettonSwapAction import io.tonapi.models.JettonTransferAction -suspend fun Action.getTonAmountRaw(ratesRepository: RatesRepository): Coins { +suspend fun Action.getTonAmountRaw(network: TonNetwork, ratesRepository: RatesRepository): Coins { val tonAmount = tonTransfer?.let { Coins.of(it.amount) } val jettonAmountInTON = jettonTransfer?.let { val amountCoins = it.amountCoins val jettonAddress = it.jetton.address - val rates = ratesRepository.getRates(WalletCurrency.TON, jettonAddress) + val rates = ratesRepository.getRates(network, WalletCurrency.TON, jettonAddress) rates.convert(jettonAddress, amountCoins) } return tonAmount ?: jettonAmountInTON ?: Coins.ZERO @@ -27,7 +23,7 @@ suspend fun Action.getTonAmountRaw(ratesRepository: RatesRepository): Coins { val JettonSwapAction.tokenIn: TokenEntity get() { - val jetton = jettonMasterIn?.let { TokenEntity(it) } + val jetton = jettonMasterIn?.toTokenEntity() return jetton ?: TokenEntity.TON } @@ -42,7 +38,7 @@ val JettonSwapAction.amountCoinsIn: Coins val JettonSwapAction.tokenOut: TokenEntity get() { - val jetton = jettonMasterOut?.let { TokenEntity(it) } + val jetton = jettonMasterOut?.toTokenEntity() return jetton ?: TokenEntity.TON } @@ -51,49 +47,3 @@ val JettonSwapAction.amountCoinsOut: Coins val tonAmount = tonOut ?: return Coins.ofNano(amountOut, tokenOut.decimals) return Coins.of(tonAmount, tokenOut.decimals) } - - -@get:DrawableRes -val ActionType.iconRes: Int - get() = when (this) { - ActionType.Received, ActionType.NftReceived, ActionType.JettonMint -> R.drawable.ic_tray_arrow_down_28 - ActionType.Send, ActionType.NftSend, ActionType.AuctionBid -> UIKitIcon.ic_tray_arrow_up_28 - ActionType.CallContract, ActionType.DepositStake, ActionType.Unknown -> UIKitIcon.ic_gear_28 - ActionType.Swap -> R.drawable.ic_swap_horizontal_alternative_28 - ActionType.SetSignatureAllowed, ActionType.AddExtension, ActionType.DeployContract, ActionType.WithdrawStakeRequest, ActionType.WithdrawStake -> UIKitIcon.ic_donemark_28 - ActionType.DomainRenewal -> R.drawable.ic_return_28 - ActionType.NftPurchase, ActionType.Purchase -> R.drawable.ic_shopping_bag_28 - ActionType.JettonBurn, ActionType.GasRelay -> R.drawable.ic_fire_28 - ActionType.SetSignatureNotAllowed, ActionType.RemoveExtension, ActionType.UnSubscribe -> R.drawable.ic_xmark_28 - ActionType.Subscribe -> R.drawable.ic_bell_28 - ActionType.Fee, ActionType.Refund -> R.drawable.ic_ton_28 - } - - -@get:StringRes -val ActionType.nameRes: Int - get() = when (this) { - ActionType.Received, ActionType.NftReceived, ActionType.JettonMint -> Localization.received - ActionType.Send, ActionType.NftSend -> Localization.sent - ActionType.CallContract -> Localization.call_contract - ActionType.Swap -> Localization.swap - ActionType.DeployContract -> Localization.wallet_initialized - ActionType.DepositStake -> Localization.stake - ActionType.AuctionBid -> Localization.bid - ActionType.WithdrawStakeRequest -> Localization.unstake_request - ActionType.DomainRenewal -> Localization.domain_renew - ActionType.WithdrawStake -> Localization.unstake - ActionType.Unknown -> Localization.unknown - ActionType.NftPurchase -> Localization.nft_purchase - ActionType.JettonBurn -> Localization.burned - ActionType.UnSubscribe -> Localization.unsubscribed - ActionType.Subscribe -> Localization.subscribed - ActionType.Fee -> Localization.network_fee - ActionType.Refund -> Localization.refund - ActionType.Purchase -> Localization.purchase - ActionType.GasRelay -> Localization.battery - ActionType.AddExtension -> Localization.added_extension - ActionType.RemoveExtension -> Localization.removed_extension - ActionType.SetSignatureAllowed -> Localization.signature_allowed - ActionType.SetSignatureNotAllowed -> Localization.signature_not_allowed - } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/HistoryHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/HistoryHelper.kt index bff4c09dc..68fa110c9 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/HistoryHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/HistoryHelper.kt @@ -1,15 +1,15 @@ package com.tonapps.tonkeeper.core.history import android.content.Context -import android.util.Log import androidx.collection.arrayMapOf +import com.tonapps.blockchain.contract.Blockchain import com.tonapps.blockchain.ton.extensions.equalsAddress -import com.tonapps.icu.Coins import com.tonapps.extensions.max24 import com.tonapps.extensions.short4 import com.tonapps.extensions.shortTron import com.tonapps.extensions.withMinus import com.tonapps.extensions.withPlus +import com.tonapps.icu.Coins import com.tonapps.icu.CurrencyFormatter import com.tonapps.tonkeeper.api.fee import com.tonapps.tonkeeper.api.getNameOrAddress @@ -20,40 +20,40 @@ import com.tonapps.tonkeeper.api.shortAddress import com.tonapps.tonkeeper.api.title import com.tonapps.tonkeeper.core.history.list.item.HistoryItem import com.tonapps.tonkeeper.core.history.list.item.HistoryItem.Event.Comment.Type -import io.tonapi.models.AccountAddress -import io.tonapi.models.AccountEvent -import io.tonapi.models.ActionSimplePreview -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import com.tonapps.tonkeeper.extensions.with import com.tonapps.tonkeeper.helper.DateHelper import com.tonapps.tonkeeper.ui.screen.dialog.encrypted.EncryptedCommentScreen -import com.tonapps.tonkeeper.ui.screen.send.main.state.SendFee -import com.tonapps.tonkeeper.usecase.emulation.Emulated +import com.tonapps.deposit.screens.send.state.SendFee +import com.tonapps.deposit.usecase.emulation.Emulated import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import com.tonapps.wallet.api.tron.entity.TronEventEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.battery.BatteryRepository import com.tonapps.wallet.data.collectibles.CollectiblesRepository -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.events.ActionType -import com.tonapps.wallet.data.events.CommentEncryption +import com.tonapps.blockchain.model.legacy.CommentEncryption +import com.tonapps.wallet.api.extensions.toTokenEntity import com.tonapps.wallet.data.events.EventsRepository import com.tonapps.wallet.data.passcode.PasscodeManager import com.tonapps.wallet.data.rates.RatesRepository import com.tonapps.wallet.data.settings.SettingsRepository import com.tonapps.wallet.localization.Localization import com.tonapps.wallet.localization.Plurals +import io.tonapi.models.AccountAddress +import io.tonapi.models.AccountEvent import io.tonapi.models.Action +import io.tonapi.models.ActionSimplePreview import io.tonapi.models.JettonVerificationType +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take +import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -85,10 +85,11 @@ class HistoryHelper( private fun getGroupKey(timestamp: Long): String { try { val calendar = Calendar.getInstance() - calendar.timeInMillis = timestamp * 1000 + calendar.timeInMillis = timestamp val now = Calendar.getInstance() val yearDiff = now.get(Calendar.YEAR) - calendar.get(Calendar.YEAR) - val monthDiff = yearDiff * 12 + now.get(Calendar.MONTH) - calendar.get(Calendar.MONTH) + val monthDiff = + yearDiff * 12 + now.get(Calendar.MONTH) - calendar.get(Calendar.MONTH) return if (monthDiff < 1) { dayMonthFormatter.format(calendar.time) @@ -130,9 +131,7 @@ class HistoryHelper( return list .filter { it is HistoryItem.Event || it is HistoryItem.App } .distinctBy { it.uniqueId } - .sortedWith { a, b -> - (b.timestampForSort - a.timestampForSort).toInt() - } + .sortedByDescending { it.timestampForSort } } fun groupByDate(items: List): List { @@ -156,9 +155,9 @@ class HistoryHelper( section.events.add(event) output[groupKey] = section } - return output.map { it.value.get() }.flatten().sortedWith { a, b -> - (b.timestampForSort - a.timestampForSort).toInt() - }.distinctBy { it.uniqueId }.toList() + + return output.map { it.value.get() }.flatten().distinctBy { it.uniqueId } + .sortedByDescending { it.timestampForSort }.toList() } fun requestDecryptComment( @@ -178,7 +177,8 @@ class HistoryHelper( } it }.map { wallet -> - val privateKey = accountRepository.getPrivateKey(wallet.id) ?: throw Exception("Private key not found") + val privateKey = + accountRepository.getPrivateKey(wallet.id) ?: throw Exception("Private key not found") val decrypted = CommentEncryption.decryptComment( wallet.publicKey, privateKey, @@ -236,7 +236,8 @@ class HistoryHelper( } val feeFormat = "≈ " + CurrencyFormatter.format("TON", emulated.extra.value) - val feeFiatFormat = CurrencyFormatter.formatFiat(emulated.currency.code, emulated.extra.fiat) + val feeFiatFormat = + CurrencyFormatter.formatFiat(emulated.currency.code, emulated.extra.fiat) val value = if (fee is SendFee.Battery) { "≈ " + context.resources.getQuantityString( @@ -341,7 +342,8 @@ class HistoryHelper( options: ActionOptions, ): List { if (event == null) { - val position = if (options.positionExtra == 0) ListCell.Position.SINGLE else ListCell.Position.FIRST + val position = + if (options.positionExtra == 0) ListCell.Position.SINGLE else ListCell.Position.FIRST return listOf(createFakeUnknown(position)) } return mapping(wallet, listOf(event), options) @@ -352,7 +354,7 @@ class HistoryHelper( eventId: String, options: ActionOptions, ): List { - val events = eventsRepository.getSingle(eventId, wallet.testnet) ?: return emptyList() + val events = eventsRepository.getSingle(eventId, wallet.network) ?: return emptyList() return mapping( wallet = wallet, events = events, @@ -395,7 +397,8 @@ class HistoryHelper( amount.withPlus } - val amountFull = CurrencyFormatter.formatFull(token.symbol, event.amount, token.decimals) + val amountFull = + CurrencyFormatter.formatFull(token.symbol, event.amount, token.decimals) val valueFullFormatted = if (event.from == tronAddress) { amountFull.withMinus } else { @@ -474,7 +477,7 @@ class HistoryHelper( val currency = settingsRepository.currency - val rates = ratesRepository.getRates(currency, TokenEntity.TON.symbol) + val rates = ratesRepository.getRates(wallet.network, currency, TokenEntity.TON.symbol) val feeInCurrency = rates.convert(TokenEntity.TON.symbol, fee) val refundInCurrency = rates.convert(TokenEntity.TON.symbol, refund) @@ -484,7 +487,8 @@ class HistoryHelper( var actionOutStatusAny = 0 for ((actionIndex, action) in actions.withIndex()) { - val isScam = event.isScam || settingsRepository.isSpamTransaction(wallet.id, event.eventId) + val isScam = + event.isScam || settingsRepository.isSpamTransaction(wallet.id, event.eventId) if (options.spamFilter == ActionOptions.SpamFilter.SPAM && !isScam) { continue @@ -508,13 +512,16 @@ class HistoryHelper( ActionOutStatus.Any -> actionOutStatusAny++ ActionOutStatus.Received -> actionOutStatusReceived++ ActionOutStatus.Send -> actionOutStatusSend++ - ActionOutStatus.App, ActionOutStatus.dApps -> { } + ActionOutStatus.App, ActionOutStatus.dApps -> {} } chunkItems.add( item.copy( pending = pending, - position = ListCell.getPosition(actions.size + options.positionExtra, actionIndex), + position = ListCell.getPosition( + actions.size + options.positionExtra, + actionIndex + ), fee = if (fee.isPositive) CurrencyFormatter.format( TokenEntity.TON.symbol, fee, @@ -576,7 +583,8 @@ class HistoryHelper( ): HistoryItem.Event? { val simplePreview = action.simplePreview val date = DateHelper.formatTransactionTime(timestamp, settingsRepository.getLocale()) - val dateDetails = DateHelper.formatTransactionDetailsTime(timestamp, settingsRepository.getLocale()) + val dateDetails = + DateHelper.formatTransactionDetailsTime(timestamp, settingsRepository.getLocale()) // actionArgs.isTon && !actionArgs.isOut && !actionArgs.isScam && actionArgs.comment != null if (action.withdrawTokenStakeRequest != null) { @@ -590,8 +598,9 @@ class HistoryHelper( Coins.ofNano(it.value, it.decimals) } ?: Coins.ZERO val value = CurrencyFormatter.format(jettonSymbol, coins).withPlus - val valueFullFormatted = CurrencyFormatter.formatFull(jettonSymbol, coins, jettonDecimals) - val rates = ratesRepository.getRates(currency, jettonAddress) + val valueFullFormatted = + CurrencyFormatter.formatFull(jettonSymbol, coins, jettonDecimals) + val rates = ratesRepository.getRates(wallet.network, currency, jettonAddress) val inCurrency = rates.convert(jettonAddress, coins) return HistoryItem.Event( @@ -627,8 +636,9 @@ class HistoryHelper( Coins.ofNano(it.value, it.decimals) } ?: Coins.ZERO val value = CurrencyFormatter.format(jettonSymbol, coins).withMinus - val valueFullFormatted = CurrencyFormatter.formatFull(jettonSymbol, coins, jettonDecimals) - val rates = ratesRepository.getRates(currency, jettonAddress) + val valueFullFormatted = + CurrencyFormatter.formatFull(jettonSymbol, coins, jettonDecimals) + val rates = ratesRepository.getRates(wallet.network, currency, jettonAddress) val inCurrency = rates.convert(jettonAddress, coins) return HistoryItem.Event( @@ -665,7 +675,7 @@ class HistoryHelper( val valueFullFormatted = CurrencyFormatter.formatFull(tokenCode, amount, token.decimals) val isOut = wallet.isMyAddress(purchase.destination.address) - val rates = ratesRepository.getRates(currency, token.address) + val rates = ratesRepository.getRates(wallet.network, currency, token.address) val inCurrency = rates.convert(token.address, amount) return HistoryItem.Event( @@ -702,13 +712,13 @@ class HistoryHelper( val amountIn = jettonSwap.amountCoinsIn val amountOut = jettonSwap.amountCoinsOut - val value = CurrencyFormatter.format(tokenOut.symbol, amountOut).withPlus - val value2 = CurrencyFormatter.format(tokenIn.symbol, amountIn).withMinus + val value = CurrencyFormatter.format(tokenOut.symbol, tokenIn.toUIAmount(amountOut)).withPlus + val value2 = CurrencyFormatter.format(tokenIn.symbol, tokenOut.toUIAmount(amountIn)).withMinus - val valueFullFormatted = CurrencyFormatter.formatFull(tokenOut.symbol, amountOut, tokenOut.decimals).withPlus - val valueFullFormatted2 = CurrencyFormatter.formatFull(tokenIn.symbol, amountIn, tokenIn.decimals).withMinus + val valueFullFormatted = CurrencyFormatter.formatFull(tokenOut.symbol, tokenOut.toUIAmount(amountOut), tokenOut.decimals).withPlus + val valueFullFormatted2 = CurrencyFormatter.formatFull(tokenIn.symbol, tokenOut.toUIAmount(amountIn), tokenIn.decimals).withMinus - val rates = ratesRepository.getRates(currency, tokenIn.address) + val rates = ratesRepository.getRates(wallet.network, currency, tokenIn.address) val inCurrency = rates.convert(tokenIn.address, amountIn) return HistoryItem.Event( @@ -739,7 +749,7 @@ class HistoryHelper( ) } else if (action.jettonTransfer != null) { val jettonTransfer = action.jettonTransfer!! - val token = jettonTransfer.jetton.address + val token = jettonTransfer.jetton.toTokenEntity() if (options.safeMode && jettonTransfer.jetton.verification != JettonVerificationType.whitelist) { return null @@ -753,8 +763,8 @@ class HistoryHelper( } ?: false val amount = Coins.ofNano(jettonTransfer.amount, jettonTransfer.jetton.decimals) - var value = CurrencyFormatter.format(symbol, amount) - var valueFullFormatted = CurrencyFormatter.formatFull(symbol, amount, jettonTransfer.jetton.decimals) + var value = CurrencyFormatter.format(symbol, token.toUIAmount(amount)) + var valueFullFormatted = CurrencyFormatter.formatFull(symbol, token.toUIAmount(amount), jettonTransfer.jetton.decimals) val itemAction: ActionType val accountAddress: AccountAddress? @@ -776,8 +786,8 @@ class HistoryHelper( valueFullFormatted = valueFullFormatted.withPlus } - val rates = ratesRepository.getRates(currency, token) - val inCurrency = rates.convert(token, amount) + val rates = ratesRepository.getRates(wallet.network, currency, token.address) + val inCurrency = rates.convert(token.address, amount) val isEncryptedComment = jettonTransfer.encryptedComment != null val comment = HistoryItem.Event.Comment.create( @@ -800,7 +810,7 @@ class HistoryHelper( comment = comment, value = value, valueFullFormatted = valueFullFormatted, - tokenAddress = token, + tokenAddress = token.address, tokenCode = "", coinIconUrl = jettonTransfer.jetton.image, timestamp = timestamp, @@ -814,13 +824,13 @@ class HistoryHelper( unverifiedToken = jettonTransfer.jetton.verification != JettonVerificationType.whitelist, isScam = isScam, wallet = wallet, - isMaybeSpam = action.getTonAmountRaw(ratesRepository) < api.config.reportAmount, + isMaybeSpam = action.getTonAmountRaw(wallet.network, ratesRepository) < api.getConfig(wallet.network).reportAmount, spamState = settingsRepository.getSpamStateTransaction(wallet.id, txId), actionOutStatus = if (isOut) ActionOutStatus.Send else ActionOutStatus.Received ) } else if (action.tonTransfer != null) { val tonTransfer = action.tonTransfer!! - val batteryConfig = batteryRepository.getConfig(wallet.testnet) + val batteryConfig = batteryRepository.getConfig(wallet.network) val isOut = !wallet.isMyAddress(tonTransfer.recipient.address) @@ -846,7 +856,7 @@ class HistoryHelper( isFromBattery = batteryConfig.gasProxy.contains(accountAddress.address) } - val rates = ratesRepository.getRates(currency, TokenEntity.TON.symbol) + val rates = ratesRepository.getRates(wallet.network, currency, TokenEntity.TON.symbol) val inCurrency = rates.convert(TokenEntity.TON.symbol, amount) val isEncryptedComment = tonTransfer.encryptedComment != null @@ -878,7 +888,7 @@ class HistoryHelper( failed = action.status == Action.Status.failed, isScam = isScam, wallet = wallet, - isMaybeSpam = action.getTonAmountRaw(ratesRepository) < api.config.reportAmount, + isMaybeSpam = action.getTonAmountRaw(wallet.network, ratesRepository) < api.getConfig(wallet.network).reportAmount, spamState = settingsRepository.getSpamStateTransaction(wallet.id, txId), actionOutStatus = if (isOut || isFromBattery || wallet.isMyAddress(tonTransfer.sender.address)) ActionOutStatus.Send else ActionOutStatus.Received ) @@ -939,7 +949,7 @@ class HistoryHelper( var nftItem = if (isScam) null else collectiblesRepository.getNft( accountId = wallet.accountId, - testnet = wallet.testnet, + network = wallet.network, address = nftItemTransfer.nft )?.let { val pref = settingsRepository.getTokenPrefs(wallet.id, it.address) @@ -1047,7 +1057,11 @@ class HistoryHelper( val amount = jettonMint.parsedAmount val value = CurrencyFormatter.format(jettonMint.jetton.symbol, amount) - val valueFullFormatted = CurrencyFormatter.formatFull(jettonMint.jetton.symbol, amount, jettonMint.jetton.decimals) + val valueFullFormatted = CurrencyFormatter.formatFull( + jettonMint.jetton.symbol, + amount, + jettonMint.jetton.decimals + ) return HistoryItem.Event( index = index, @@ -1134,7 +1148,8 @@ class HistoryHelper( val tokenCode = auctionBid.amount.tokenName val value = CurrencyFormatter.format(auctionBid.amount.tokenName, amount) - val valueFullFormatted = CurrencyFormatter.formatFull(auctionBid.amount.tokenName, amount, 9) + val valueFullFormatted = + CurrencyFormatter.formatFull(auctionBid.amount.tokenName, amount, 9) return HistoryItem.Event( index = index, @@ -1206,11 +1221,12 @@ class HistoryHelper( val amount = Coins.of(nftPurchase.amount.value.toLong()) val value = CurrencyFormatter.format(nftPurchase.amount.tokenName, amount) - val valueFullFormatted = CurrencyFormatter.formatFull(nftPurchase.amount.tokenName, amount, 9) + val valueFullFormatted = + CurrencyFormatter.formatFull(nftPurchase.amount.tokenName, amount, 9) val nftItem = if (isScam) null else collectiblesRepository.getNft( accountId = wallet.accountId, - testnet = wallet.testnet, + network = wallet.network, address = nftPurchase.nft.address )?.let { val pref = settingsRepository.getTokenPrefs(wallet.id, it.address) @@ -1247,7 +1263,11 @@ class HistoryHelper( val amount = jettonBurn.parsedAmount val value = CurrencyFormatter.format(jettonBurn.jetton.symbol, amount) - val valueFullFormatted = CurrencyFormatter.formatFull(jettonBurn.jetton.symbol, amount, jettonBurn.jetton.decimals) + val valueFullFormatted = CurrencyFormatter.formatFull( + jettonBurn.jetton.symbol, + amount, + jettonBurn.jetton.decimals + ) return HistoryItem.Event( index = index, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryAdapter.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryAdapter.kt index 07b2ab464..741b84d19 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryAdapter.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryAdapter.kt @@ -10,7 +10,7 @@ import com.tonapps.tonkeeper.core.history.list.holder.HistoryFailedHolder import com.tonapps.tonkeeper.core.history.list.holder.HistoryHeaderHolder import com.tonapps.tonkeeper.core.history.list.holder.HistoryLoaderHolder import com.tonapps.tonkeeper.core.history.list.item.HistoryItem -import com.tonapps.tonkeeper.ui.screen.send.main.state.SendFee +import com.tonapps.deposit.screens.send.state.SendFee import com.tonapps.uikit.list.BaseListAdapter import com.tonapps.uikit.list.BaseListHolder import com.tonapps.uikit.list.BaseListItem diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryItemDecoration.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryItemDecoration.kt index 49b99f176..51aca5a07 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryItemDecoration.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/HistoryItemDecoration.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.core.history.list import android.graphics.Rect -import android.util.Log +import com.tonapps.log.L import android.view.View import androidx.core.view.doOnLayout import androidx.recyclerview.widget.RecyclerView diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/holder/HistoryActionHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/holder/HistoryActionHolder.kt index 0d0e5cac9..65151f4fa 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/holder/HistoryActionHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/holder/HistoryActionHolder.kt @@ -17,12 +17,12 @@ import com.tonapps.extensions.logError import com.tonapps.extensions.max24 import com.tonapps.icu.CurrencyFormatter.withCustomSymbol import com.tonapps.tonkeeper.core.history.HistoryHelper -import com.tonapps.tonkeeper.core.history.iconRes import com.tonapps.tonkeeper.core.history.list.item.HistoryItem -import com.tonapps.tonkeeper.core.history.nameRes +import com.tonapps.wallet.features.events.iconRes +import com.tonapps.wallet.features.events.nameRes import com.tonapps.tonkeeper.koin.historyHelper import com.tonapps.tonkeeper.ui.screen.nft.NftScreen -import com.tonapps.tonkeeper.ui.screen.send.main.state.SendFee +import com.tonapps.deposit.screens.send.state.SendFee import com.tonapps.tonkeeper.ui.screen.transaction.TransactionScreen import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.accentGreenColor diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/item/HistoryItem.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/item/HistoryItem.kt index 650afac3e..3edcebf4e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/item/HistoryItem.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/history/list/item/HistoryItem.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.os.Parcel import android.os.Parcelable import androidx.core.net.toUri +import com.tonapps.blockchain.contract.Blockchain import com.tonapps.blockchain.ton.extensions.toUserFriendly import com.tonapps.extensions.locale import com.tonapps.extensions.readBooleanCompat @@ -16,11 +17,10 @@ import com.tonapps.extensions.writeCharSequenceCompat import com.tonapps.extensions.writeEnum import com.tonapps.tonkeeper.core.history.ActionOutStatus import com.tonapps.tonkeeper.helper.DateHelper -import com.tonapps.tonkeeper.ui.screen.send.main.state.SendFee +import com.tonapps.deposit.screens.send.state.SendFee import com.tonapps.uikit.list.BaseListItem import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.entities.NftEntity import com.tonapps.wallet.data.dapps.entities.AppPushEntity import com.tonapps.wallet.data.events.ActionType diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/signer/SingerArgs.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/signer/SingerArgs.kt deleted file mode 100644 index 2ad38d95a..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/signer/SingerArgs.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.tonapps.tonkeeper.core.signer - -import android.net.Uri -import com.tonapps.blockchain.ton.extensions.publicKeyFromHex -import org.ton.api.pub.PublicKeyEd25519 - -data class SingerArgs( - val publicKeyEd25519: PublicKeyEd25519, - val name: String?, -) { - - constructor(uri: Uri): this( - publicKeyEd25519 = uri.getQueryParameter("pk")?.publicKeyFromHex() ?: throw IllegalArgumentException("Public key is required"), - name = uri.getQueryParameter("name"), - ) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/signer/SingerResultContract.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/signer/SingerResultContract.kt index ea6b5b79a..b1cc20d13 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/signer/SingerResultContract.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/core/signer/SingerResultContract.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.core.signer import android.app.Activity import android.content.Context import android.content.Intent -import android.util.Log +import com.tonapps.log.L import androidx.activity.result.contract.ActivityResultContract import org.ton.api.pub.PublicKeyEd25519 import org.ton.bitstring.BitString diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLink.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLink.kt deleted file mode 100644 index 6d7498db0..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/deeplink/DeepLink.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.tonapps.tonkeeper.deeplink - -import android.net.Uri -import androidx.core.net.toUri - -data class DeepLink( - val route: DeepLinkRoute, - val fromQR: Boolean, - val referrer: Uri?, -) { - - companion object { - - fun fixBadUri(uri: Uri): Uri { - return fixBadUrl(uri.toString()).toUri() - } - - fun fixBadUrl(url: String): String { - var fixedUrl = url.replace("tg:resolve", "tg://resolve") - fixedUrl = fixedUrl.replace("\\u0026", "&") - return fixedUrl - } - } - - constructor( - uri: Uri, - fromQR: Boolean, - referrer: Uri? - ): this( - route = DeepLinkRoute.resolve(uri), - fromQR = fromQR, - referrer = referrer - ) - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/AccountRepository.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/AccountRepository.kt deleted file mode 100644 index 92fa7d9b9..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/AccountRepository.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.tonapps.tonkeeper.extensions - -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.tonapps.security.tryCallGC -import com.tonapps.tonkeeper.ui.screen.send.main.SendException -import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.rn.RNException -import com.tonapps.wallet.data.rn.RNLegacy -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.ton.api.pk.PrivateKeyEd25519 -import org.ton.mnemonic.Mnemonic -import uikit.navigation.NavigationActivity - - -suspend fun AccountRepository.sign( - activity: NavigationActivity, - rnLegacy: RNLegacy, - walletId: String, - bytes: ByteArray -): ByteArray { - var privateKey: PrivateKeyEd25519? = requestPrivateKey(activity, rnLegacy, walletId) - val signature = privateKey?.sign(bytes) ?: throw SendException.UnableSendTransaction() - privateKey = null - tryCallGC() - return signature -} - -suspend fun AccountRepository.requestPrivateKey( - activity: NavigationActivity, - rnLegacy: RNLegacy, - walletId: String, -): PrivateKeyEd25519? = withContext(Dispatchers.IO) { - try { - val privateKeyEd25519 = getPrivateKey(walletId) - if (privateKeyEd25519 != null) { - privateKeyEd25519 - } else { - val vaultState = rnLegacy.requestVault(activity) - val mnemonic = vaultState.getDecryptedData(walletId)?.mnemonic ?: throw RNException.NotFoundMnemonic(walletId) - val seed = Mnemonic.toSeed(splitMnemonic(mnemonic)) - PrivateKeyEd25519(seed) - } - } catch (e: Throwable) { - FirebaseCrashlytics.getInstance().recordException(e) - null - } -} - -private fun splitMnemonic(mnemonic: String): List { - val words = if (mnemonic.contains(",")) { - mnemonic.split(",") - } else { - mnemonic.split(" ") - } - return words.map { it.trim() } -} - diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/ActionSheet.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/ActionSheet.kt index 647ff1a50..1d8c8edcc 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/ActionSheet.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/ActionSheet.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.extensions import com.tonapps.extensions.uri import com.tonapps.tonkeeper.popup.ActionSheet import com.tonapps.tonkeeper.popup.ActionSheet.Item -import com.tonapps.tonkeeper.ui.screen.send.main.state.SendFee +import com.tonapps.deposit.screens.send.state.SendFee import com.tonapps.uikit.color.accentGreenColor import com.tonapps.uikit.icon.UIKitIcon import com.tonapps.wallet.localization.Localization diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/BaseFragment.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/BaseFragment.kt index 674a0889c..c9ca36fff 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/BaseFragment.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/BaseFragment.kt @@ -4,7 +4,7 @@ import uikit.base.BaseFragment import uikit.extensions.hideKeyboard fun BaseFragment.hideKeyboard() { - requireActivity().hideKeyboard() + activity?.hideKeyboard() } fun BaseFragment.finishDelay(delay: Long = 3000) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Coins.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Coins.kt deleted file mode 100644 index 0d98e0bee..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Coins.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.tonapps.tonkeeper.extensions - -import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.tonapps.icu.Coins -import java.math.BigInteger - -fun Coins.toGrams(): org.ton.block.Coins { - val value = toBigInteger() - if (value < BigInteger.ZERO) { - val exception = IllegalArgumentException("Value must be positive!\n" + - "BigDecimal: ${this.value}\n" + - "decimals: ${this.decimals}\n" + - "long: $value") - - FirebaseCrashlytics.getInstance().recordException(exception) - throw exception - } - return org.ton.block.Coins.ofNano(value) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Context.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Context.kt index 17c7490d3..a3c9a53c3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Context.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Context.kt @@ -15,14 +15,11 @@ import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan -import android.util.Log import androidx.annotation.ColorInt import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.runtime.Composable import androidx.core.content.ContextCompat import androidx.work.WorkManager -import coil3.compose.LocalPlatformContext import com.tonapps.blockchain.ton.contract.WalletVersion import com.tonapps.extensions.bestMessage import com.tonapps.tonkeeper.koin.settingsRepository @@ -32,8 +29,8 @@ import com.tonapps.uikit.color.accentGreenColor import com.tonapps.uikit.color.accentRedColor import com.tonapps.uikit.color.backgroundContentTintColor import com.tonapps.uikit.color.textSecondaryColor -import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.data.account.Wallet +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.blockchain.model.legacy.WalletType import com.tonapps.wallet.localization.Localization import ui.ComposeIcon import uikit.navigation.Navigation @@ -78,11 +75,7 @@ fun Context.safeExternalOpenUri(uri: Uri) { fun Context.safeCanRequestPackageInstalls(): Boolean { return try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - packageManager.canRequestPackageInstalls() - } else { - true - } + packageManager.canRequestPackageInstalls() } catch (e: Throwable) { false } @@ -119,7 +112,7 @@ fun Context.copyToClipboard(uri: Uri) { fun Context.copyToClipboard(text: String, sensitive: Boolean = false) { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clip = ClipData.newPlainText("", text) - if (sensitive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (sensitive) { val extras = PersistableBundle() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) @@ -175,7 +168,7 @@ fun Context.getStringCompat(@StringRes resId: Int, vararg formatArgs: CharSequen } fun Context.getWalletBadges( - type: Wallet.Type, + type: WalletType, version: WalletVersion ): CharSequence { var builder = SpannableStringBuilder() @@ -189,13 +182,14 @@ fun Context.getWalletBadges( builder = builder.badgeGreen(this, resId) } - if (type != Wallet.Type.Default) { + if (type != WalletType.Default) { val resId = when (type) { - Wallet.Type.Watch -> Localization.watch_only - Wallet.Type.Testnet -> Localization.testnet - Wallet.Type.Signer, Wallet.Type.SignerQR -> Localization.signer - Wallet.Type.Ledger -> Localization.ledger - Wallet.Type.Keystone -> Localization.keystone + WalletType.Watch -> Localization.watch_only + WalletType.Testnet -> Localization.testnet + WalletType.Signer, WalletType.SignerQR -> Localization.signer + WalletType.Ledger -> Localization.ledger + WalletType.Keystone -> Localization.keystone + WalletType.Tetra -> Localization.tetra else -> throw IllegalArgumentException("Unknown wallet type: $type") } builder = builder.badgeDefault(this, resId) @@ -219,4 +213,4 @@ fun Context.openAppSettings() { intent.setData(Uri.fromParts("package", packageName, null)) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK startActivity(intent) -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/DAppsRepository.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/DAppsRepository.kt index e019ce1d0..fbcd509b3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/DAppsRepository.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/DAppsRepository.kt @@ -1,10 +1,10 @@ package com.tonapps.tonkeeper.extensions import android.net.Uri -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.filterList import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.BrowserRepository import com.tonapps.wallet.data.dapps.DAppsRepository import com.tonapps.wallet.data.dapps.entities.AppEntity @@ -53,7 +53,7 @@ suspend fun DAppsRepository.getAppFixIcon( var app = getApp(url) val browserApp = browserRepository.getApp( country = settingsRepository.country, - testnet = wallet.testnet, + network = wallet.network, locale = settingsRepository.getLocale(), uri = url ) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/PurchaseRepository.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/PurchaseRepository.kt deleted file mode 100644 index 8e43959e8..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/PurchaseRepository.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.tonapps.tonkeeper.extensions - -import com.tonapps.tonkeeper.Environment -import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.purchase.PurchaseRepository -import com.tonapps.wallet.data.purchase.entity.OnRamp -import com.tonapps.wallet.data.purchase.entity.PurchaseMethodEntity -import com.tonapps.wallet.data.settings.SettingsRepository -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.withContext - -data class OnRampResult( - val data: OnRamp.Data, - val country: String -) - - -suspend fun PurchaseRepository.getProvidersByCountry( - wallet: WalletEntity, - settingsRepository: SettingsRepository, - country: String -): List = withContext(Dispatchers.IO) { - val methods = get(wallet.testnet, country, settingsRepository.getLocale()) ?: return@withContext emptyList() - val all = methods.first + methods.second - all.map { it.items }.flatten().distinctBy { it.title } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/RNLegacy.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/RNLegacy.kt deleted file mode 100644 index fb33b13e4..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/RNLegacy.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.tonapps.tonkeeper.extensions - -import com.tonapps.wallet.data.passcode.dialog.PasscodeDialog -import com.tonapps.wallet.data.rn.RNException -import com.tonapps.wallet.data.rn.RNLegacy -import com.tonapps.wallet.data.rn.data.RNVaultState -import com.tonapps.wallet.data.rn.data.RNWallets -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import uikit.navigation.NavigationActivity - -suspend fun RNLegacy.requestVault( - activity: NavigationActivity -): RNVaultState = withContext(Dispatchers.IO) { - val wallets = getWallets() - if (wallets.count == 0) { - throw IllegalStateException("No wallets found") - } - val passcode = requestPasscode(activity, wallets) - getVaultState(passcode) -} - -private suspend fun RNLegacy.requestPasscode( - activity: NavigationActivity, - wallets: RNWallets -): String = withContext(Dispatchers.Main) { - val passcodeFromBiometry = if (wallets.biometryEnabled) { - exportPasscodeWithBiometry() - } else { - null - } - val passcode = if (!passcodeFromBiometry.isNullOrBlank()) { - passcodeFromBiometry - } else { - PasscodeDialog.request(activity) - } - - passcode ?: throw RNException.NotFoundPasscode -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SendFee.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SendFee.kt index c5e219396..4a51157ba 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SendFee.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SendFee.kt @@ -2,8 +2,9 @@ package com.tonapps.tonkeeper.extensions import android.content.Context import com.tonapps.icu.CurrencyFormatter -import com.tonapps.tonkeeper.ui.screen.send.main.state.SendFee +import com.tonapps.deposit.screens.send.state.SendFee import com.tonapps.wallet.data.settings.entities.PreferredFeeMethod +import com.tonapps.wallet.data.settings.entities.PreferredTronFeeMethod import com.tonapps.wallet.localization.Plurals val SendFee.id: String @@ -30,9 +31,18 @@ fun SendFee.Battery.formattedCharges(context: Context): CharSequence { ) } -val SendFee.method: PreferredFeeMethod +val SendFee.method: PreferredFeeMethod? get() = when (this) { is SendFee.Gasless -> PreferredFeeMethod.GASLESS is SendFee.Battery -> PreferredFeeMethod.BATTERY is SendFee.Ton -> PreferredFeeMethod.TON + else -> null + } + +val SendFee.tronMethod: PreferredTronFeeMethod? + get() = when (this) { + is SendFee.Battery -> PreferredTronFeeMethod.BATTERY + is SendFee.TronTrx -> PreferredTronFeeMethod.TRX + is SendFee.TronTon -> PreferredTronFeeMethod.TON + else -> null } \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SettingsRepository.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SettingsRepository.kt index ed9f11d20..c9c4e888a 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SettingsRepository.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SettingsRepository.kt @@ -1,6 +1,7 @@ package com.tonapps.tonkeeper.extensions import com.tonapps.wallet.api.API +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.data.settings.SafeModeState import com.tonapps.wallet.data.settings.SettingsRepository import kotlinx.coroutines.Dispatchers @@ -9,10 +10,10 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import java.util.Locale -fun SettingsRepository.isSafeModeEnabled(api: API): Boolean { +fun SettingsRepository.isSafeModeEnabled(api: API, network: TonNetwork): Boolean { val state = getSafeModeState() if (state == SafeModeState.Default) { - return api.config.flags.safeModeEnabled + return api.getConfig(network).flags.safeModeEnabled } return state == SafeModeState.Enabled } \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SignRequestEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SignRequestEntity.kt index 330e7f662..ef46b0036 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SignRequestEntity.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/SignRequestEntity.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.extensions import com.tonapps.blockchain.ton.TonSendMode import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.core.entity.SignRequestEntity import com.tonapps.wallet.data.token.entities.AccountTokenEntity import kotlinx.coroutines.Dispatchers @@ -22,7 +22,7 @@ suspend fun SignRequestEntity.getTransfers( val transferMessages = getTransferMessages(batteryEnabled) val transfers = mutableListOf() for (message in transferMessages) { - val sendMode = if (tonBalance != null && message.coins == tonBalance) { + val sendMode = if (tonBalance != null && message.amount == tonBalance.toBigInteger()) { TonSendMode.CARRY_ALL_REMAINING_BALANCE.value + TonSendMode.IGNORE_ERRORS.value } else { TonSendMode.PAY_GAS_SEPARATELY.value + TonSendMode.IGNORE_ERRORS.value @@ -35,7 +35,7 @@ suspend fun SignRequestEntity.getTransfers( it.balance.walletAddress.equalsAddress(message.addressValue) } val jettonCustomPayload = jetton?.let { - api.getJettonCustomPayload(wallet.accountId, wallet.testnet, it.address) + api.getJettonCustomPayload(wallet.accountId, wallet.network, it.address) } val transfer = message.getWalletTransfer( diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/String.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/String.kt index 984d38eb4..d65958981 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/String.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/String.kt @@ -8,15 +8,6 @@ import com.tonapps.uikit.icon.UIKitIcon import uikit.extensions.drawable import uikit.span.ImageSpanCompat -fun String.substringSafe(startIndex: Int, endIndex: Int): String { - return if (startIndex > length) { - "" - } else if (endIndex > length) { - substring(startIndex) - } else { - substring(startIndex, endIndex) - } -} val String.capitalized: String get() { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Theme.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Theme.kt index 05360b9e3..4d1468d3c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Theme.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Theme.kt @@ -1,8 +1,10 @@ package com.tonapps.tonkeeper.extensions +import androidx.compose.runtime.Composable import com.tonapps.wallet.data.core.Theme import ui.theme.AppColorScheme +@Composable fun Theme.compose(): AppColorScheme { return when (key) { "dark" -> ui.theme.appColorSchemeDark() diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/TokenEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/TokenEntity.kt deleted file mode 100644 index 265af15af..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/TokenEntity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.tonapps.tonkeeper.extensions - -import com.tonapps.wallet.api.entity.value.Blockchain -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency -import com.tonapps.wallet.data.core.currency.WalletCurrency.Chain - -val TokenEntity.asCurrency: WalletCurrency - get() = WalletCurrency( - code = symbol, - title = name, - chain = if (blockchain == Blockchain.TRON) Chain.TRON(address, decimals) else Chain.TON(address, decimals), - iconUrl = imageUri.toString(), - ) \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Wallet.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Wallet.kt index 482d50eb0..678d899f8 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Wallet.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/Wallet.kt @@ -2,11 +2,11 @@ package com.tonapps.tonkeeper.extensions import android.content.Context import android.text.SpannableString -import android.util.Log import androidx.appcompat.widget.AppCompatTextView import com.tonapps.emoji.Emoji import com.tonapps.uikit.color.textPrimaryColor -import com.tonapps.wallet.data.account.Wallet +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.blockchain.model.legacy.WalletType import com.tonapps.wallet.localization.Localization import uikit.extensions.drawable import uikit.extensions.sp diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WalletCurrency.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WalletCurrency.kt index 88489a05d..e7a6340c1 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WalletCurrency.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WalletCurrency.kt @@ -2,34 +2,12 @@ package com.tonapps.tonkeeper.extensions import android.content.Context import android.text.SpannableStringBuilder -import com.tonapps.wallet.api.R -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import uikit.extensions.badgeBlue import uikit.extensions.badgeDefault import uikit.extensions.badgeOrange import uikit.extensions.badgeRed -fun WalletCurrency.Chain.iconExternalUrl(context: Context): String? { - return when (this) { - is WalletCurrency.Chain.TON -> context.externalDrawableUrl(R.drawable.ic_ton_with_bg) - is WalletCurrency.Chain.ETC -> context.externalDrawableUrl(R.drawable.ic_eth_with_bg) - is WalletCurrency.Chain.TRON -> context.externalDrawableUrl(com.tonapps.tonkeeperx.R.drawable.ic_tron) - else -> { - null - } - } -} - -fun WalletCurrency.iconExternalUrl(context: Context): String? { - return if (this == WalletCurrency.TON) { - context.externalDrawableUrl(R.drawable.ic_ton_with_bg) - } else if (code == WalletCurrency.USDT_KEY) { - context.externalDrawableUrl(R.drawable.ic_usdt_with_bg) - } else { - iconUrl - } -} - fun WalletCurrency.spannableCode(context: Context): CharSequence { if (!isUSDT) { return code.trim() diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WebView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WebView.kt index 68024b6b1..4220d7dae 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WebView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/extensions/WebView.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.extensions import android.annotation.SuppressLint -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import uikit.widget.webview.WebViewFixed @SuppressLint("RequiresFeature") diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BatteryHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BatteryHelper.kt index 523ac45cb..37e81da76 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BatteryHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BatteryHelper.kt @@ -1,12 +1,11 @@ package com.tonapps.tonkeeper.helper import com.tonapps.icu.Coins -import com.tonapps.tonkeeper.usecase.emulation.Emulated -import com.tonapps.tonkeeper.usecase.emulation.EmulationUseCase -import com.tonapps.wallet.api.API +import com.tonapps.deposit.usecase.emulation.Emulated +import com.tonapps.deposit.usecase.emulation.EmulationUseCase import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.MessageBodyEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.MessageBodyEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.battery.BatteryMapper import com.tonapps.wallet.data.battery.BatteryRepository import com.tonapps.wallet.data.settings.BatteryTransaction @@ -22,7 +21,7 @@ object BatteryHelper { batteryRepository: BatteryRepository ): Int = withContext(Dispatchers.IO) { accountRepository.requestTonProofToken(wallet)?.let { - batteryRepository.getCharges(it, wallet.publicKey, wallet.testnet, true) + batteryRepository.getCharges(it, wallet.publicKey, wallet.network, true) } ?: 0 } @@ -35,7 +34,7 @@ object BatteryHelper { val entity = batteryRepository.getBalance( tonProofToken = tonProof, publicKey = wallet.publicKey, - testnet = wallet.testnet, + network = wallet.network, ignoreCache = true ) return entity.balance @@ -51,7 +50,7 @@ object BatteryHelper { params: Boolean ): Emulated? { val chargesBalance = getBatteryCharges(wallet, accountRepository, batteryRepository) - val batteryConfig = batteryRepository.getConfig(wallet.testnet) + val batteryConfig = batteryRepository.getConfig(wallet.network) val emulated = emulationUseCase( message = message, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BrowserHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BrowserHelper.kt index e8698979c..6e1a05073 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BrowserHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/BrowserHelper.kt @@ -1,31 +1,26 @@ package com.tonapps.tonkeeper.helper import android.app.Activity -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.provider.Browser -import android.util.Log import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.net.toUri import com.tonapps.extensions.activity import com.tonapps.extensions.locale import com.tonapps.extensions.toUriOrNull -import com.tonapps.tonkeeper.core.AnalyticsHelper -import com.tonapps.tonkeeper.core.entities.WalletPurchaseMethodEntity +import com.tonapps.legacy.enteties.WalletPurchaseMethodEntity import com.tonapps.tonkeeper.extensions.showToast -import com.tonapps.tonkeeper.koin.installId +import com.tonapps.tonkeeper.koin.analytics import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppScreen import com.tonapps.uikit.color.backgroundPageColor import com.tonapps.uikit.color.textPrimaryColor -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.entities.BrowserAppEntity import com.tonapps.wallet.localization.Localization import uikit.navigation.Navigation -import androidx.core.net.toUri -import com.tonapps.tonkeeper.koin.analytics object BrowserHelper { @@ -38,7 +33,7 @@ object BrowserHelper { } else if (useTG) { openTG(context, url) } - context.analytics?.trackEventClickDApp( + context.analytics?.dappClick( url = url.toString(), name = name, source = source, @@ -47,12 +42,13 @@ object BrowserHelper { } else { Navigation.from(context)?.add( DAppScreen.newInstance( - wallet = wallet, - title = name, - url = url, - iconUrl = icon.toString(), - source = source - )) + wallet = wallet, + title = name, + url = url, + iconUrl = icon.toString(), + source = source + ) + ) } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/CacheHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/CacheHelper.kt index 561f7e2b5..8527b656b 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/CacheHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/CacheHelper.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.helper import com.tonapps.tonkeeper.core.history.list.item.HistoryItem -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.core.ScreenCacheSource class CacheHelper( diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/DateHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/DateHelper.kt index 09f6a3aa9..f7d1b3211 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/DateHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/DateHelper.kt @@ -1,33 +1,27 @@ package com.tonapps.tonkeeper.helper import android.content.Context -import android.os.Build -import androidx.annotation.RequiresApi import com.tonapps.extensions.currentTimeSeconds import com.tonapps.tonkeeper.extensions.capitalized import com.tonapps.wallet.localization.Localization import kotlinx.datetime.DateTimePeriod -import kotlin.time.Clock import kotlinx.datetime.DateTimeUnit -import kotlinx.datetime.LocalDateTime -import kotlin.time.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.number import kotlinx.datetime.plus import kotlinx.datetime.toJavaLocalDateTime -import kotlinx.datetime.toJavaZoneId import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.todayIn -import java.text.SimpleDateFormat import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatterBuilder import java.util.Date import java.util.Locale +import kotlin.time.Clock import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.ExperimentalTime -import kotlin.time.toJavaInstant +import kotlin.time.Instant object DateHelper { @@ -125,6 +119,13 @@ object DateHelper { } } + @OptIn(ExperimentalTime::class) + fun isToday(timestamp: Long): Boolean { + val today = Clock.System.todayIn(currentTz) + return Instant.fromEpochSeconds(timestamp) + .toLocalDateTime(currentTz).date == today + } + @OptIn(ExperimentalTime::class) fun isToday(date: Instant): Boolean { val today = Clock.System.todayIn(currentTz) @@ -151,7 +152,6 @@ object DateHelper { return now.year == other.year && now.month.number == other.month.number } - @RequiresApi(Build.VERSION_CODES.O) private fun createModernFormatter(pattern: String, locale: Locale): DateTimeFormatter { return DateTimeFormatterBuilder() .appendPattern(pattern) @@ -159,18 +159,12 @@ object DateHelper { } @OptIn(ExperimentalTime::class) - @RequiresApi(Build.VERSION_CODES.O) private fun formatModernDate(instant: Instant, formatString: String, locale: Locale): String { val formatter = createModernFormatter(formatString, locale) val zonedDateTime = instant.toLocalDateTime(currentTz).toJavaLocalDateTime() return formatter.format(zonedDateTime) } - private fun formatLegacyDate(date: Date, formatString: String, locale: Locale): String { - val formatter = SimpleDateFormat(formatString, locale) - return formatter.format(date) - } - @OptIn(ExperimentalTime::class) fun formatDate( instant: Instant, @@ -182,10 +176,8 @@ object DateHelper { if (declensionMonth) { val sdf = android.icu.text.SimpleDateFormat(formatString, locale) return sdf.format(date) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return formatModernDate(instant, formatString, locale) } else { - return formatLegacyDate(date, formatString, locale) + return formatModernDate(instant, formatString, locale) } } @@ -215,4 +207,4 @@ object DateHelper { return String.format("%02d:%02d:%02d", hours, minutes, seconds) } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/NotificationsHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/NotificationsHelper.kt index e3fd45c28..17601a053 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/NotificationsHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/NotificationsHelper.kt @@ -4,7 +4,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri -import android.util.Log +import com.tonapps.log.L import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/ShortcutHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/ShortcutHelper.kt index 96d1c31a0..03a6a9ff3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/ShortcutHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/ShortcutHelper.kt @@ -15,11 +15,10 @@ import androidx.core.graphics.drawable.toBitmap import com.tonapps.emoji.Emoji import com.tonapps.extensions.isValid import com.tonapps.extensions.max18 -import com.tonapps.tonkeeper.App import com.tonapps.uikit.color.accentBlueColor import com.tonapps.uikit.color.backgroundContentColor import com.tonapps.uikit.color.textPrimaryColor -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import uikit.extensions.dp import uikit.extensions.drawable import androidx.core.net.toUri diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/TwinInput.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/TwinInput.kt index 272b0d46c..20e264ff8 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/TwinInput.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/helper/TwinInput.kt @@ -1,8 +1,8 @@ package com.tonapps.tonkeeper.helper -import android.util.Log +import com.tonapps.log.L import com.tonapps.icu.Coins -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.rates.RateData import com.tonapps.wallet.data.rates.entity.RatesEntity import kotlinx.coroutines.CoroutineScope diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/Extension.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/Extension.kt index 906d3a41d..65960ad49 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/Extension.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/Extension.kt @@ -6,14 +6,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.CreationExtras import androidx.recyclerview.widget.RecyclerView -import com.tonapps.tonkeeper.RemoteConfig -import com.tonapps.tonkeeper.core.AnalyticsHelper +import com.tonapps.core.flags.RemoteConfig +import com.tonapps.bus.core.AnalyticsHelper import com.tonapps.tonkeeper.core.history.HistoryHelper import com.tonapps.tonkeeper.manager.apk.APKManager import com.tonapps.tonkeeper.manager.push.PushManager import com.tonapps.tonkeeper.ui.base.BaseWalletScreen import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeper.ui.base.compose.ComposeWalletScreen +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.wallet.api.API import com.tonapps.wallet.api.entity.ConfigEntity import com.tonapps.wallet.api.entity.FlagsEntity @@ -85,10 +86,10 @@ val Context.remoteConfig: RemoteConfig? get() = koin?.get() val Context.serverConfig: ConfigEntity? - get() = api?.config + get() = api?.getConfig(TonNetwork.MAINNET) val Context.serverFlags: FlagsEntity? - get() = api?.config?.flags + get() = api?.getConfig(TonNetwork.MAINNET)?.flags val Context.settingsRepository: SettingsRepository? get() = koin?.get() diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/KoinModule.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/KoinModule.kt index c0eddb901..0d9b3ecb9 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/KoinModule.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/KoinModule.kt @@ -1,13 +1,14 @@ package com.tonapps.tonkeeper.koin +import com.tonapps.async.Async import com.tonapps.network.NetworkMonitor import com.tonapps.tonkeeper.Environment -import com.tonapps.tonkeeper.RemoteConfig +import com.tonapps.core.flags.RemoteConfig import com.tonapps.tonkeeper.billing.BillingManager import com.tonapps.tonkeeper.client.safemode.SafeModeClient -import com.tonapps.tonkeeper.core.AnalyticsHelper +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.core.helper.EnvironmentHelper import com.tonapps.tonkeeper.manager.assets.AssetsManager -import com.tonapps.tonkeeper.manager.tx.TransactionManager import com.tonapps.tonkeeper.core.history.HistoryHelper import com.tonapps.tonkeeper.helper.CacheHelper import com.tonapps.tonkeeper.helper.ReferrerClientHelper @@ -15,7 +16,9 @@ import com.tonapps.tonkeeper.manager.apk.APKManager import com.tonapps.tonkeeper.manager.push.PushManager import com.tonapps.tonkeeper.ui.screen.main.MainViewModel import com.tonapps.tonkeeper.ui.screen.root.RootViewModel +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager +import com.tonapps.tonkeeper.manager.TonConnectBridgeResolver import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.base.picker.currency.CurrencyPickerViewModel import com.tonapps.tonkeeper.ui.screen.add.AddWalletViewModel @@ -24,8 +27,8 @@ import com.tonapps.tonkeeper.ui.screen.browser.main.BrowserMainViewModel import com.tonapps.tonkeeper.ui.screen.browser.search.BrowserSearchViewModel import com.tonapps.tonkeeper.ui.screen.country.CountryPickerViewModel import com.tonapps.tonkeeper.ui.screen.dev.DevViewModel -import com.tonapps.tonkeeper.ui.screen.settings.currency.CurrencyViewModel import com.tonapps.tonkeeper.ui.screen.init.InitViewModel +import com.tonapps.tonkeeper.ui.screen.settings.currency.CurrencyViewModel import com.tonapps.tonkeeper.ui.screen.ledger.steps.LedgerConnectionViewModel import com.tonapps.tonkeeper.ui.screen.migration.MigrationViewModel import com.tonapps.tonkeeper.ui.screen.settings.language.LanguageViewModel @@ -36,12 +39,14 @@ import com.tonapps.tonkeeper.ui.screen.settings.security.SecurityViewModel import com.tonapps.tonkeeper.ui.screen.settings.theme.ThemeViewModel import com.tonapps.tonkeeper.ui.screen.stories.w5.W5StoriesViewModel import com.tonapps.tonkeeper.ui.screen.tonconnect.TonConnectViewModel -import com.tonapps.tonkeeper.usecase.emulation.EmulationUseCase -import com.tonapps.tonkeeper.usecase.sign.SignUseCase +import com.tonapps.deposit.usecase.emulation.EmulationUseCase +import com.tonapps.deposit.usecase.sign.SignUseCase +import com.tonapps.tonkeeper.App +import com.tonapps.tonkeeper.worker.WidgetUpdaterWorker import com.tonapps.wallet.data.settings.SettingsRepository -import kotlinx.coroutines.CoroutineScope +import com.tonapps.wallet.data.tx.TransactionManager import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import org.koin.core.module.dsl.bind import org.koin.core.module.dsl.factoryOf import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.viewModelOf @@ -50,24 +55,37 @@ import org.koin.dsl.module val koinModel = module { factory { Dispatchers.Default } - single(createdAtStart = true) { CoroutineScope(Dispatchers.IO + SupervisorJob()) } - singleOf(::Environment) + single(createdAtStart = true) { Async.ioScope() } + singleOf(TonConnectBridgeResolver::create) { bind() } + + singleOf(::Environment) // TODO move to data module + single { get() } + singleOf(::EnvironmentHelper) // TODO remove singleOf(::RemoteConfig) singleOf(::SettingsRepository) singleOf(::NetworkMonitor) singleOf(::HistoryHelper) singleOf(::AssetsManager) + single { get() } // TODO remove singleOf(::BillingManager) singleOf(::TransactionManager) + single { // TODO remove + object : TransactionManager.Delegate { + override fun onUpdateWidget() { + WidgetUpdaterWorker.update(App.instance) + } + } + } singleOf(::TonConnectManager) singleOf(::PushManager) singleOf(::SafeModeClient) singleOf(::APKManager) singleOf(::CacheHelper) singleOf(::ReferrerClientHelper) - singleOf(::AnalyticsHelper) + singleOf(AnalyticsHelper::Default) + factoryOf(::SignUseCase) factoryOf(::SignUseCase) factoryOf(::EmulationUseCase) @@ -95,4 +113,4 @@ val koinModel = module { viewModelOf(::AddWalletViewModel) viewModelOf(::BatteryViewModel) viewModelOf(BaseWalletVM::EmptyViewViewModel) -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/viewModelWalletModule.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/viewModelWalletModule.kt index 9ca742dd7..7d5a1fb83 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/viewModelWalletModule.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/koin/viewModelWalletModule.kt @@ -1,58 +1,67 @@ package com.tonapps.tonkeeper.koin -import org.koin.dsl.module -import com.tonapps.tonkeeper.ui.screen.wallet.main.WalletViewModel -import com.tonapps.tonkeeper.ui.screen.settings.main.SettingsViewModel -import com.tonapps.tonkeeper.ui.screen.name.edit.EditNameViewModel -import com.tonapps.tonkeeper.ui.screen.events.main.EventsViewModel -import com.tonapps.tonkeeper.ui.screen.collectibles.main.CollectiblesViewModel -import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppViewModel -import com.tonapps.tonkeeper.ui.screen.notifications.NotificationsManageViewModel -import com.tonapps.tonkeeper.ui.screen.token.viewer.TokenViewModel -import com.tonapps.tonkeeper.ui.screen.backup.main.BackupViewModel +import com.tonapps.dapp.warning.DAppConfirmFeature +import com.tonapps.deposit.screens.assets.AssetsCryptoExtendedFeature +import com.tonapps.deposit.screens.buy.crypto.BuyWithCryptoFeature +import com.tonapps.deposit.screens.confirm.ConfirmFeature +import com.tonapps.deposit.screens.currency.SelectCurrencyFeature +import com.tonapps.deposit.screens.method.PaymentMethodFeature +import com.tonapps.deposit.screens.network.SelectNetworkFeature +import com.tonapps.deposit.screens.picker.TokenPickerFeature +import com.tonapps.deposit.screens.qr.QrAssetFeature +import com.tonapps.deposit.screens.ramp.RampFeature +import com.tonapps.deposit.screens.ramp.amount.DepositAmountFeature +import com.tonapps.deposit.screens.send.SendFeature import com.tonapps.tonkeeper.ui.screen.backup.check.BackupCheckViewModel -import com.tonapps.tonkeeper.ui.screen.wallet.manage.TokensManageViewModel -import com.tonapps.tonkeeper.ui.screen.send.main.SendViewModel -import com.tonapps.tonkeeper.ui.screen.token.picker.TokenPickerViewModel -import com.tonapps.tonkeeper.ui.screen.battery.settings.BatterySettingsViewModel -import com.tonapps.tonkeeper.ui.screen.battery.refill.BatteryRefillViewModel +import com.tonapps.tonkeeper.ui.screen.backup.main.BackupViewModel import com.tonapps.tonkeeper.ui.screen.battery.recharge.BatteryRechargeViewModel +import com.tonapps.tonkeeper.ui.screen.battery.refill.BatteryRefillViewModel +import com.tonapps.tonkeeper.ui.screen.battery.settings.BatterySettingsViewModel import com.tonapps.tonkeeper.ui.screen.browser.base.BrowserBaseViewModel +import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppViewModel import com.tonapps.tonkeeper.ui.screen.browser.more.BrowserMoreViewModel import com.tonapps.tonkeeper.ui.screen.card.CardViewModel +import com.tonapps.tonkeeper.ui.screen.collectibles.main.CollectiblesViewModel import com.tonapps.tonkeeper.ui.screen.collectibles.manage.CollectiblesManageViewModel import com.tonapps.tonkeeper.ui.screen.dns.renew.DNSRenewViewModel import com.tonapps.tonkeeper.ui.screen.events.compose.details.TxDetailsViewModel -import com.tonapps.tonkeeper.ui.screen.events.spam.SpamEventsViewModel import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxEventsViewModel -import com.tonapps.tonkeeper.ui.screen.send.contacts.main.SendContactsViewModel -import com.tonapps.tonkeeper.ui.screen.purchase.PurchaseViewModel +import com.tonapps.tonkeeper.ui.screen.events.spam.SpamEventsViewModel +import com.tonapps.tonkeeper.ui.screen.name.edit.EditNameViewModel import com.tonapps.tonkeeper.ui.screen.nft.NftViewModel -import com.tonapps.tonkeeper.ui.screen.onramp.main.OnRampViewModel -import com.tonapps.tonkeeper.ui.screen.onramp.picker.currency.OnRampPickerViewModel -import com.tonapps.tonkeeper.ui.screen.onramp.picker.provider.OnRampProviderPickerViewModel -import com.tonapps.tonkeeper.ui.screen.qr.QRViewModel +import com.tonapps.tonkeeper.ui.screen.notifications.NotificationsManageViewModel +import com.tonapps.tonkeeper.ui.screen.send.boc.RemoveExtensionViewModel import com.tonapps.tonkeeper.ui.screen.send.contacts.add.AddContactViewModel import com.tonapps.tonkeeper.ui.screen.send.contacts.edit.EditContactViewModel -import com.tonapps.tonkeeper.ui.screen.staking.viewer.StakeViewerViewModel -import com.tonapps.tonkeeper.ui.screen.staking.unstake.UnStakeViewModel -import com.tonapps.tonkeeper.ui.screen.staking.stake.StakingViewModel +import com.tonapps.tonkeeper.ui.screen.send.contacts.main.SendContactsViewModel +import com.tonapps.tonkeeper.ui.screen.send.main.SendViewModel import com.tonapps.tonkeeper.ui.screen.send.transaction.SendTransactionViewModel import com.tonapps.tonkeeper.ui.screen.settings.apps.AppsViewModel import com.tonapps.tonkeeper.ui.screen.settings.extensions.ExtensionsViewModel -import com.tonapps.tonkeeper.ui.screen.send.boc.RemoveExtensionViewModel +import com.tonapps.tonkeeper.ui.screen.settings.main.SettingsViewModel import com.tonapps.tonkeeper.ui.screen.sign.SignDataViewModel +import com.tonapps.tonkeeper.ui.screen.staking.stake.StakingViewModel +import com.tonapps.tonkeeper.ui.screen.staking.unstake.UnStakeViewModel +import com.tonapps.tonkeeper.ui.screen.staking.viewer.StakeViewerViewModel import com.tonapps.tonkeeper.ui.screen.staking.withdraw.StakeWithdrawViewModel import com.tonapps.tonkeeper.ui.screen.swap.omniston.OmnistonViewModel import com.tonapps.tonkeeper.ui.screen.swap.picker.SwapPickerViewModel +import com.tonapps.tonkeeper.ui.screen.token.picker.TokenPickerViewModel +import com.tonapps.tonkeeper.ui.screen.token.viewer.TokenViewModel import com.tonapps.tonkeeper.ui.screen.transaction.TransactionViewModel +import com.tonapps.tonkeeper.ui.screen.tronfees.TronFeesViewModel +import com.tonapps.tonkeeper.ui.screen.wallet.main.WalletViewModel +import com.tonapps.tonkeeper.ui.screen.wallet.manage.TokensManageViewModel +import com.tonapps.trading.screens.assets.AssetsFeature +import com.tonapps.trading.screens.details.AssetDetailsFeature +import com.tonapps.trading.screens.shelves.ShelvesFeature import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module val viewModelWalletModule = module { viewModelOf(::WalletViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::EditNameViewModel) - viewModelOf(::EventsViewModel) viewModelOf(::CollectiblesViewModel) viewModelOf(::DAppViewModel) viewModelOf(::NotificationsManageViewModel) @@ -60,13 +69,9 @@ val viewModelWalletModule = module { viewModelOf(::BackupViewModel) viewModelOf(::BackupCheckViewModel) viewModelOf(::TokensManageViewModel) - viewModelOf(::SendViewModel) - viewModelOf(::TokenPickerViewModel) viewModelOf(::BatterySettingsViewModel) viewModelOf(::BatteryRefillViewModel) viewModelOf(::BatteryRechargeViewModel) - viewModelOf(::SendContactsViewModel) - viewModelOf(::PurchaseViewModel) viewModelOf(::NftViewModel) viewModelOf(::StakeViewerViewModel) viewModelOf(::UnStakeViewModel) @@ -80,18 +85,36 @@ val viewModelWalletModule = module { viewModelOf(::ExtensionsViewModel) viewModelOf(::CollectiblesManageViewModel) viewModelOf(::CardViewModel) - viewModelOf(::QRViewModel) viewModelOf(::TransactionViewModel) viewModelOf(::BrowserMoreViewModel) viewModelOf(::BrowserBaseViewModel) viewModelOf(::SpamEventsViewModel) viewModelOf(::SignDataViewModel) - viewModelOf(::OnRampViewModel) - viewModelOf(::OnRampProviderPickerViewModel) - viewModelOf(::OnRampPickerViewModel) viewModelOf(::OmnistonViewModel) viewModelOf(::SwapPickerViewModel) viewModelOf(::DNSRenewViewModel) + viewModelOf(::TronFeesViewModel) + + viewModelOf(::TokenPickerViewModel) + viewModelOf(::SendContactsViewModel) + viewModelOf(::SendViewModel) + viewModelOf(::TxEventsViewModel) viewModelOf(::TxDetailsViewModel) -} \ No newline at end of file + + viewModelOf(::RampFeature) + viewModelOf(::AssetsCryptoExtendedFeature) + viewModelOf(::PaymentMethodFeature) + viewModelOf(::DepositAmountFeature) + viewModelOf(::QrAssetFeature) + viewModelOf(::BuyWithCryptoFeature) + viewModelOf(::SendFeature) + viewModelOf(::ConfirmFeature) + viewModelOf(::DAppConfirmFeature) + viewModelOf(::SelectCurrencyFeature) + viewModelOf(::SelectNetworkFeature) + viewModelOf(::TokenPickerFeature) + viewModelOf(::ShelvesFeature) + viewModelOf(::AssetsFeature) + viewModelOf(::AssetDetailsFeature) +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/TonConnectBridgeResolver.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/TonConnectBridgeResolver.kt new file mode 100644 index 000000000..779efa243 --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/TonConnectBridgeResolver.kt @@ -0,0 +1,52 @@ +package com.tonapps.tonkeeper.manager + +import android.content.Context +import com.tonapps.tonkeeper.manager.push.PushManager +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge +import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager +import com.tonapps.tonkeeper.manager.walletkit.WalletKitTonConnect +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.dapps.DAppsRepository +import kotlinx.coroutines.CoroutineScope + +class TonConnectBridgeResolver constructor( + private var api: API, + private val manager: TonConnectManager, + private val pushManager: PushManager, + private val context: Context, + private val scope: CoroutineScope, + private val accountRepository: AccountRepository, + private val dAppsRepository: DAppsRepository, + private val wrapped: ITonConnectBridge +) : ITonConnectBridge by wrapped { + + companion object { + fun create( + api: API, + manager: TonConnectManager, + pushManager: PushManager, + context: Context, + scope: CoroutineScope, + accountRepository: AccountRepository, + dAppsRepository: DAppsRepository + ): TonConnectBridgeResolver { + val wrapped: ITonConnectBridge = if (WalletKitTonConnect.isEnabled(api)) { + WalletKitTonConnect( + context = context, + scope = scope, + accountRepository = accountRepository, + dAppsRepository = dAppsRepository, + pushManager = pushManager, + manager = manager + ) + } else { + manager + } + + return TonConnectBridgeResolver( + api, manager, pushManager, context, scope, accountRepository, dAppsRepository, wrapped + ) + } + } +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/apk/APKManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/apk/APKManager.kt index 85f9a6c35..58bafb017 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/apk/APKManager.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/apk/APKManager.kt @@ -3,19 +3,15 @@ package com.tonapps.tonkeeper.manager.apk import android.annotation.SuppressLint import android.content.Context import android.content.Intent -import android.os.Build import android.os.Environment import android.os.Parcelable import android.provider.Settings -import android.util.Log import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.core.net.toUri import com.tonapps.extensions.appVersionName import com.tonapps.extensions.file -import com.tonapps.extensions.getParcelable -import com.tonapps.extensions.putParcelable -import com.tonapps.tonkeeper.RemoteConfig +import com.tonapps.core.flags.RemoteConfig import com.tonapps.tonkeeper.extensions.safeCanRequestPackageInstalls import com.tonapps.tonkeeper.worker.ApkDownloadWorker import com.tonapps.tonkeeperx.BuildConfig @@ -44,11 +40,6 @@ class APKManager( private val settingsRepository: SettingsRepository, ) { - companion object { - private const val UPDATE_REMINDER_TIMESTAMP_KEY = "apk_update_reminder_timestamp" - private const val UPDATE_REMINDER_COUNT_KEY = "apk_update_reminder_count" - } - @Parcelize data class HideReminder( val timestamp: Long = 0, @@ -156,7 +147,7 @@ class APKManager( return false } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.safeCanRequestPackageInstalls()) { + if (!context.safeCanRequestPackageInstalls()) { openSettings() } else { val uri = FileProvider.getUriForFile(context, context.packageName + ".provider", file) @@ -177,4 +168,9 @@ class APKManager( } private fun isValidFile(file: File) = file.path.startsWith(folder.path) -} \ No newline at end of file + + companion object { + private const val UPDATE_REMINDER_TIMESTAMP_KEY = "apk_update_reminder_timestamp" + private const val UPDATE_REMINDER_COUNT_KEY = "apk_update_reminder_count" + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/AssetsManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/AssetsManager.kt index 76f02cb82..d6e15f52b 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/AssetsManager.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/AssetsManager.kt @@ -1,21 +1,20 @@ package com.tonapps.tonkeeper.manager.assets import android.content.Context +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.deposit.usecase.emulation.EmulationUseCase import com.tonapps.icu.Coins -import com.tonapps.icu.Coins.Companion.sumOf -import com.tonapps.tonkeeper.core.entities.AssetsEntity -import com.tonapps.tonkeeper.core.entities.AssetsEntity.Companion.sort -import com.tonapps.tonkeeper.core.entities.StakedEntity -import com.tonapps.tonkeeper.extensions.isSafeModeEnabled +import com.tonapps.legacy.enteties.AssetsEntity +import com.tonapps.legacy.enteties.StakedEntity +import com.tonapps.tonkeeper.core.sort +import com.tonapps.tonkeeper.core.sumOfVerifiedFiat import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.TokenEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency import com.tonapps.wallet.data.rates.RatesRepository import com.tonapps.wallet.data.settings.SettingsRepository -import com.tonapps.wallet.data.staking.StakingPool import com.tonapps.wallet.data.staking.StakingRepository import com.tonapps.wallet.data.staking.entities.StakingEntity import com.tonapps.wallet.data.token.TokenRepository @@ -37,7 +36,7 @@ class AssetsManager( private val settingsRepository: SettingsRepository, private val accountRepository: AccountRepository, private val api: API, -) { +) : EmulationUseCase.Delegate { private val cache = TotalBalanceCache(context) @@ -65,6 +64,7 @@ class AssetsManager( tokenUsde?.let { if (tokenTsUsde != null) { val rates = ratesRepository.getRates( + wallet.network, currency, listOf(it.address, tokenTsUsde.address) ) @@ -99,12 +99,6 @@ class AssetsManager( return list } - suspend fun getTONBalance( - wallet: WalletEntity, currency: WalletCurrency = settingsRepository.currency - ) = getToken( - wallet = wallet, token = "TON", currency = currency - )?.balance ?: Coins.ZERO - suspend fun getToken( wallet: WalletEntity, token: String, currency: WalletCurrency = settingsRepository.currency ): AssetsEntity.Token? { @@ -134,17 +128,17 @@ class AssetsManager( currency: WalletCurrency = settingsRepository.currency, refresh: Boolean, ): List { - val safeMode = settingsRepository.isSafeModeEnabled(api) + val safeMode = settingsRepository.isSafeModeEnabled(wallet.network) val tronAddress = if (wallet.hasPrivateKey && !wallet.testnet) { accountRepository.getTronAddress(wallet.id) } else null val tokens = - tokenRepository.get(currency, wallet.accountId, wallet.testnet, refresh, tronAddress) + tokenRepository.get(currency, wallet.accountId, wallet.network, refresh, tronAddress) ?: return emptyList() tokens.firstOrNull()?.let { if (wallet.initialized != it.balance.initializedAccount) { - accountRepository.setInitialized(wallet.accountId, it.balance.initializedAccount) + accountRepository.setInitialized(wallet.id, it.balance.initializedAccount) } } return if (safeMode) { @@ -161,15 +155,19 @@ class AssetsManager( refresh: Boolean, ): List { val staking = getStaking(wallet, refresh) - val staked = StakedEntity.create(wallet, staking, tokens, currency, ratesRepository, api) + val staked = StakedEntity.create(wallet, staking, tokens, currency, ratesRepository) return staked.map { AssetsEntity.Staked(it) } } private suspend fun getStaking( - wallet: WalletEntity, refresh: Boolean + wallet: WalletEntity, + refresh: Boolean ): StakingEntity { return stakingRepository.get( - accountId = wallet.accountId, testnet = wallet.testnet, ignoreCache = refresh + accountId = wallet.accountId, + network = wallet.network, + ignoreCache = refresh, + initializedAccount = wallet.initialized ) } @@ -212,7 +210,13 @@ class AssetsManager( if (sorted) { assets = assets.sort(wallet, settingsRepository) } - return assets.map { it.fiat }.sumOf { it } + return assets.sumOfVerifiedFiat() } + override suspend fun getTotalBalance( + wallet: WalletEntity, + currency: WalletCurrency + ): Coins? { + return getTotalBalance(wallet, currency, false) + } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/TotalBalanceCache.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/TotalBalanceCache.kt index 43399beeb..e6bdce8c6 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/TotalBalanceCache.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/TotalBalanceCache.kt @@ -6,8 +6,8 @@ import com.tonapps.extensions.getParcelable import com.tonapps.extensions.prefs import com.tonapps.extensions.putParcelable import com.tonapps.icu.Coins -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency internal class TotalBalanceCache(context: Context) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/WalletBalanceEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/WalletBalanceEntity.kt index e0c98b17a..df11aed23 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/WalletBalanceEntity.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/assets/WalletBalanceEntity.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.manager.assets import com.tonapps.icu.Coins -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity data class WalletBalanceEntity( val accountId: String, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/FirebasePush.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/FirebasePush.kt index d4c82538f..6900381ad 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/FirebasePush.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/FirebasePush.kt @@ -54,4 +54,4 @@ class FirebasePush: FirebaseMessagingService() { } } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/PushManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/PushManager.kt index d6d987b50..0f9757f10 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/PushManager.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/push/PushManager.kt @@ -1,12 +1,12 @@ package com.tonapps.tonkeeper.manager.push import android.content.Context -import android.util.Log +import com.tonapps.log.L import androidx.core.app.NotificationManagerCompat import com.tonapps.extensions.locale import com.tonapps.wallet.api.API import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.dapps.DAppsRepository import com.tonapps.wallet.data.dapps.entities.AppConnectEntity import com.tonapps.wallet.data.settings.SettingsRepository @@ -82,12 +82,12 @@ class PushManager( throw IllegalStateException("Failed to subscribe") } for (wallet in wallets) { - val apps = dAppsRepository.getConnections(wallet.accountId, wallet.testnet) + val apps = dAppsRepository.getConnections(wallet.accountId, wallet.network) for ((app, connections) in apps) { dAppPush( wallet = wallet, connections = connections, - commercial = dAppsRepository.isPushEnabled(wallet.accountId, wallet.testnet, app.url), + commercial = dAppsRepository.isPushEnabled(wallet.accountId, wallet.network, app.url), silent = true ) } @@ -119,7 +119,7 @@ class PushManager( for (wallet in wallets) { settingsRepository.setPushWallet(wallet.id, false) if (!delete) { - val apps = dAppsRepository.getConnections(wallet.accountId, wallet.testnet) + val apps = dAppsRepository.getConnections(wallet.accountId, wallet.network) for ((_, connections) in apps) { dAppPush( wallet = wallet, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/theme/MainContextWrapper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/theme/MainContextWrapper.kt index 43ff45b79..44309c8c6 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/theme/MainContextWrapper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/theme/MainContextWrapper.kt @@ -2,11 +2,11 @@ package com.tonapps.tonkeeper.manager.theme import android.content.Context import android.content.ContextWrapper -import android.util.Log +import com.tonapps.log.L class MainContextWrapper(base: Context): ContextWrapper(base) { init { - Log.d("RootActivityLog", "ThemeContextWrapper init") + L.d("RootActivityLog", "ThemeContextWrapper init") } } \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/ITonConnectBridge.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/ITonConnectBridge.kt new file mode 100644 index 000000000..ce301ef00 --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/ITonConnectBridge.kt @@ -0,0 +1,113 @@ +package com.tonapps.tonkeeper.manager.tonconnect + +import android.content.Context +import android.net.Uri +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.blockchain.ton.connect.TONProof +import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.BridgeError +import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.BridgeEvent +import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.SignDataRequestPayload +import com.tonapps.tonkeeper.manager.walletkit.WalletKitEvent +import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppBridge +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.wallet.data.dapps.entities.AppConnectEntity +import com.tonapps.wallet.data.dapps.entities.AppEntity +import io.ton.walletkit.request.TONWalletConnectionRequest +import com.tonapps.security.CryptoBox +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.json.JSONObject +import uikit.navigation.NavigationActivity + +/** Common interface for TonConnect implementations (legacy vs WalletKit). */ +interface ITonConnectBridge { + + val transactionRequestFlow: Flow> + + val signDataRequestFlow: Flow + + val walletKitEventFlow: Flow + get() = emptyFlow() + + fun connectBridge() {} + + fun reconnectBridge() {} + + fun disconnectBridge() {} + + fun processDeeplink( + context: Context, + uri: Uri, + fromQR: Boolean, + refSource: Uri?, + fromPackageName: String? + ): Uri? + + suspend fun sendTransactionResponseSuccess( + connection: AppConnectEntity, + boc: String, + id: Long + ) + + suspend fun sendBridgeError( + connection: AppConnectEntity, + error: BridgeError, + id: Long + ) + + suspend fun sendSignDataResponseSuccess( + connection: AppConnectEntity, + proof: TONProof.Result, + address: String, + payload: SignDataRequestPayload, + id: Long + ) + + fun walletConnectionsFlow(wallet: WalletEntity): Flow> + + fun walletAppsFlow(wallet: WalletEntity): Flow> + + fun disconnect(wallet: WalletEntity, appUrl: Uri, type: AppConnectEntity.Type? = null) + + suspend fun clear(wallet: WalletEntity) + + fun setLastAppRequestId(clientId: String, messageId: Long) + + fun isPushEnabled(wallet: WalletEntity, appUrl: Uri): Boolean + + suspend fun showLogoutAppBar(wallet: WalletEntity, context: Context, url: Uri) + + fun createInjector( + bridge: DAppBridge, + wallet: WalletEntity? = null + ): ITonConnectWebViewInjector + + suspend fun isScam(context: Context, wallet: WalletEntity, vararg uris: Uri): Boolean + + suspend fun launchConnectFlow( + activity: NavigationActivity, + tonConnect: TonConnect, + keyPair: CryptoBox.KeyPair = CryptoBox.keyPair(), + wallet: WalletEntity?, + forceConnect: Boolean = false + ): JSONObject + + suspend fun getConnection( + accountId: String, + network: TonNetwork, + appUrl: Uri, + type: AppConnectEntity.Type + ): AppConnectEntity? + + suspend fun approveConnectionRequest( + request: TONWalletConnectionRequest, + wallet: WalletEntity, + proof: TONProof.Result? = null, + pushEnabled: Boolean = false + ): Boolean = throw UnsupportedOperationException("Only supported by WalletKit") + + suspend fun rejectConnectionRequest( + request: TONWalletConnectionRequest, + reason: String? = null + ): Boolean = throw UnsupportedOperationException("Only supported by WalletKit") +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/ITonConnectWebViewInjector.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/ITonConnectWebViewInjector.kt new file mode 100644 index 000000000..92ee8f9b5 --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/ITonConnectWebViewInjector.kt @@ -0,0 +1,24 @@ +package com.tonapps.tonkeeper.manager.tonconnect + +import com.tonapps.tonkeeper.ui.component.TonConnectWebView +import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppBridge + +/** + * Strategy for injecting TonConnect into WebView. + */ +interface ITonConnectWebViewInjector { + fun inject(webView: TonConnectWebView): Boolean +} + +/** + * TonConnect injector using DAppBridge. + */ +class TonConnectWebViewInjector( + private val bridge: DAppBridge, +) : ITonConnectWebViewInjector { + + override fun inject(webView: TonConnectWebView): Boolean { + webView.setJsBridge(bridge) + return true + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnect.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnect.kt index df2771ba2..84bdd75d5 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnect.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnect.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.manager.tonconnect import android.net.Uri import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import androidx.core.net.toUri import com.tonapps.extensions.getMultipleQuery import com.tonapps.security.Security diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnectManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnectManager.kt index dd17ed508..c68528a68 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnectManager.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/TonConnectManager.kt @@ -2,12 +2,14 @@ package com.tonapps.tonkeeper.manager.tonconnect import android.content.Context import android.net.Uri -import android.util.Log +import android.util.Base64 +import android.view.View import androidx.collection.ArrayMap import androidx.core.net.toUri import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.connect.TONProof +import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.extensions.appVersionName import com.tonapps.extensions.bestMessage import com.tonapps.extensions.filterList @@ -15,10 +17,12 @@ import com.tonapps.extensions.flatter import com.tonapps.extensions.hasQuery import com.tonapps.extensions.isEmptyQuery import com.tonapps.extensions.mapList +import com.tonapps.network.OkHttpError import com.tonapps.network.simple import com.tonapps.security.CryptoBox import com.tonapps.tonkeeper.client.safemode.SafeModeClient import com.tonapps.tonkeeper.core.DevSettings +import com.tonapps.tonkeeper.core.FirebaseHelper import com.tonapps.tonkeeper.extensions.isSafeModeEnabled import com.tonapps.tonkeeper.extensions.showToast import com.tonapps.tonkeeper.manager.push.PushManager @@ -30,13 +34,15 @@ import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.BridgeMethod import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.SignDataRequestPayload import com.tonapps.tonkeeper.manager.tonconnect.exceptions.ManifestException import com.tonapps.tonkeeper.ui.component.SnackBarView +import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppBridge import com.tonapps.tonkeeper.ui.screen.tonconnect.TonConnectResponse import com.tonapps.tonkeeper.ui.screen.tonconnect.TonConnectSafeModeDialog import com.tonapps.tonkeeper.ui.screen.tonconnect.TonConnectScreen import com.tonapps.tonkeeper.worker.DAppPushToggleWorker import com.tonapps.wallet.api.API +import com.tonapps.wallet.api.readBody import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.dapps.DAppsRepository import com.tonapps.wallet.data.dapps.entities.AppConnectEntity import com.tonapps.wallet.data.dapps.entities.AppEntity @@ -51,12 +57,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject @@ -65,8 +68,8 @@ import uikit.extensions.addForResult import uikit.navigation.NavigationActivity import java.util.concurrent.CancellationException import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentMap -import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock class TonConnectManager( private val scope: CoroutineScope, @@ -76,10 +79,9 @@ class TonConnectManager( private val pushManager: PushManager, private val safeModeClient: SafeModeClient, private val settingsRepository: SettingsRepository, -) { +) : ITonConnectBridge { private val bridge: Bridge = Bridge(api) - private val bridgeConnected = AtomicBoolean(false) private var bridgeJob: Job? = null private val recentlyConnectedClients = ConcurrentHashMap() @@ -93,7 +95,7 @@ class TonConnectManager( event }.shareIn(scope, SharingStarted.Eagerly, 1) - val transactionRequestFlow = eventsFlow.mapNotNull { event -> + override val transactionRequestFlow = eventsFlow.mapNotNull { event -> if (event.method == BridgeMethod.SEND_TRANSACTION) { val connectTime = recentlyConnectedClients[event.connection.clientId] ?: 0L val timeSinceConnect = System.currentTimeMillis() - connectTime @@ -111,60 +113,67 @@ class TonConnectManager( } }.flowOn(Dispatchers.IO).shareIn(scope, SharingStarted.Eagerly, 0) - val signDataRequestFlow = eventsFlow + override val signDataRequestFlow = eventsFlow .filter { it.method == BridgeMethod.SIGN_DATA } .map { it } .shareIn(scope, SharingStarted.Eagerly, 0) - fun connectBridge() { - if (bridgeConnected.get()) { - return + private val mutex = ReentrantLock() + + override fun connectBridge() { + mutex.withLock { + if (bridgeJob?.isActive == true) { + return + } + + startBridge() + } + } + + override fun disconnectBridge() { + mutex.withLock { + bridgeJob?.cancel() + bridgeJob = null + } + } + + override fun reconnectBridge() { + mutex.withLock { + if (bridgeJob != null) { + bridgeJob?.cancel() + startBridge() + } } - bridgeJob?.cancel() - bridgeConnected.set(true) + } + private fun startBridge() { bridgeJob = scope.launch(Dispatchers.IO) { val connections = dAppsRepository.getConnections().chunked(50) if (connections.isEmpty()) { return@launch } + val flow = connections.map { bridge.eventsFlow(it, dAppsRepository.lastEventId) }.flatter() + flow.collect { - if (bridgeConnected.get()) { - _eventsFlow.emit(it) - } + _eventsFlow.emit(it) } } } - fun disconnectBridge() { - if (!bridgeConnected.get()) { - return - } - bridgeJob?.cancel() - bridgeConnected.set(false) - } + override fun walletConnectionsFlow(wallet: WalletEntity) = accountConnectionsFlow(wallet.accountId, wallet.network) - private fun reconnectBridge() { - if (bridgeConnected.get()) { - disconnectBridge() - connectBridge() - } + fun accountConnectionsFlow(accountId: String, network: TonNetwork = TonNetwork.MAINNET) = dAppsRepository.connectionsFlow.filterList { connection -> + connection.network == network && connection.accountId.equalsAddress(accountId) } - fun walletConnectionsFlow(wallet: WalletEntity) = accountConnectionsFlow(wallet.accountId, wallet.testnet) - - fun accountConnectionsFlow(accountId: String, testnet: Boolean = false) = dAppsRepository.connectionsFlow.filterList { connection -> - connection.testnet == testnet && connection.accountId.equalsAddress(accountId) - } - - fun setLastAppRequestId(clientId: String, messageId: Long) { + override fun setLastAppRequestId(clientId: String, messageId: Long) { dAppsRepository.setLastAppRequestId(clientId, messageId) } - fun walletAppsFlow(wallet: WalletEntity) = walletConnectionsFlow(wallet).mapList { it.appUrl }.map { it.distinct() }.map { urls -> + override fun walletAppsFlow(wallet: WalletEntity) = walletConnectionsFlow(wallet).mapList { it.appUrl }.map { it.distinct() }.map { urls -> dAppsRepository.getApps(urls) }.flowOn(Dispatchers.IO) @@ -176,18 +185,18 @@ class TonConnectManager( bridge.sendDisconnectResponseSuccess(connection, messageId) } - accountRepository.getWalletByAccountId(connection.accountId, connection.testnet)?.let { + accountRepository.getWalletByAccountId(connection.accountId, connection.network)?.let { pushManager.dAppUnsubscribe(it, listOf(connection)) } } - suspend fun getConnection( + override suspend fun getConnection( accountId: String, - testnet: Boolean, + network: TonNetwork, appUrl: Uri, type: AppConnectEntity.Type ): AppConnectEntity? { - val apps = dAppsRepository.getConnections(accountId, testnet) + val apps = dAppsRepository.getConnections(accountId, network) if (apps.isEmpty()) { return null } @@ -202,9 +211,9 @@ class TonConnectManager( return null } - fun disconnect(wallet: WalletEntity, appUrl: Uri, type: AppConnectEntity.Type? = null) { + override fun disconnect(wallet: WalletEntity, appUrl: Uri, type: AppConnectEntity.Type?) { scope.launch(Dispatchers.IO) { - val connections = dAppsRepository.deleteApp(wallet.accountId, wallet.testnet, appUrl, type) + val connections = dAppsRepository.deleteApp(wallet.accountId, wallet.network, appUrl, type) if (connections.isNotEmpty()) { for (connection in connections) { bridge.sendDisconnect(connection) @@ -217,31 +226,33 @@ class TonConnectManager( } } - suspend fun clear(wallet: WalletEntity) = withContext(Dispatchers.IO) { - val connections = dAppsRepository.deleteApps(wallet.accountId, wallet.testnet) - for (connection in connections) { - bridge.sendDisconnect(connection) + override suspend fun clear(wallet: WalletEntity) { + withContext(Dispatchers.IO) { + val connections = dAppsRepository.deleteApps(wallet.accountId, wallet.network) + for (connection in connections) { + bridge.sendDisconnect(connection) + } + pushManager.dAppUnsubscribe(wallet, connections) } - pushManager.dAppUnsubscribe(wallet, connections) } - suspend fun sendBridgeError(connection: AppConnectEntity, error: BridgeError, id: Long) { + override suspend fun sendBridgeError(connection: AppConnectEntity, error: BridgeError, id: Long) { bridge.sendError(connection, error, id) setLastAppRequestId(connection.clientId, id) } - suspend fun sendTransactionResponseSuccess(connection: AppConnectEntity, boc: String, id: Long) { + override suspend fun sendTransactionResponseSuccess(connection: AppConnectEntity, boc: String, id: Long) { bridge.sendTransactionResponseSuccess(connection, boc, id) setLastAppRequestId(connection.clientId, id) } - suspend fun sendSignDataResponseSuccess(connection: AppConnectEntity, proof: TONProof.Result, address: String, payload: SignDataRequestPayload, id: Long) { + override suspend fun sendSignDataResponseSuccess(connection: AppConnectEntity, proof: TONProof.Result, address: String, payload: SignDataRequestPayload, id: Long) { bridge.sendSignDataResponseSuccess(connection, proof, address, payload, id) setLastAppRequestId(connection.clientId, id) } - fun isPushEnabled(wallet: WalletEntity, appUrl: Uri): Boolean { - return dAppsRepository.isPushEnabled(wallet.accountId, wallet.testnet, appUrl) + override fun isPushEnabled(wallet: WalletEntity, appUrl: Uri): Boolean { + return dAppsRepository.isPushEnabled(wallet.accountId, wallet.network, appUrl) } private suspend fun newConnect( @@ -256,7 +267,7 @@ class TonConnectManager( val timestamp = proof?.timestamp ?: (System.currentTimeMillis() / 1000L) val connection = AppConnectEntity( accountId = wallet.accountId, - testnet = wallet.testnet, + network = wallet.network, clientId = clientId, type = type, appUrl = appUrl, @@ -272,7 +283,7 @@ class TonConnectManager( connection } - fun processDeeplink( + override fun processDeeplink( context: Context, uri: Uri, fromQR: Boolean, @@ -309,16 +320,16 @@ class TonConnectManager( private suspend fun connectRemoteApp(activity: NavigationActivity, tonConnect: TonConnect) { val keyPair = CryptoBox.keyPair() - val message = launchConnectFlow(activity, tonConnect, keyPair, null) + val message = launchConnectFlow(activity, tonConnect, keyPair, null, false) bridge.send(tonConnect.clientId, keyPair, message.toString()) } - suspend fun launchConnectFlow( + override suspend fun launchConnectFlow( activity: NavigationActivity, tonConnect: TonConnect, - keyPair: CryptoBox.KeyPair = CryptoBox.keyPair(), + keyPair: CryptoBox.KeyPair, wallet: WalletEntity?, - forceConnect: Boolean = false + forceConnect: Boolean ): JSONObject = withContext(Dispatchers.IO) { if (tonConnect.request.items.isEmpty()) { return@withContext JsonBuilder.connectEventError(BridgeError.badRequest("Empty value provided in required field \"items\"")) @@ -352,7 +363,7 @@ class TonConnectManager( screen.contract.parseResult(bundle) } - val connect = newConnect( + newConnect( wallet = response.wallet, keyPair = keyPair, clientId = clientId, @@ -385,6 +396,11 @@ class TonConnectManager( wallet?.let { showLogoutAppBar(it, activity, appUrl) } JsonBuilder.connectEventError(BridgeError.userDeclinedTransaction()) } catch (e: ManifestException) { + FirebaseHelper.manifestFetchFailed( + kind = if (e is ManifestException.NotFound) "not_found" else "content_error", + url = tonConnect.manifestUrl, + ) + withContext(Dispatchers.Main) { activity.showToast(Localization.dapp_manifest_error) } if (e is ManifestException.NotFound) { JsonBuilder.connectEventError(BridgeError.appManifestNotFound()) } else { @@ -396,19 +412,26 @@ class TonConnectManager( } } - suspend fun showLogoutAppBar(wallet: WalletEntity, context: Context, url: Uri) = withContext(Dispatchers.Main.immediate) { - val connections = dAppsRepository.getConnections(wallet.accountId, wallet.testnet).flatMap { + override suspend fun showLogoutAppBar(wallet: WalletEntity, context: Context, url: Uri) = withContext(Dispatchers.Main.immediate) { + val connections = dAppsRepository.getConnections(wallet.accountId, wallet.network).flatMap { it.value - }.filter { it.accountId.equalsAddress(wallet.accountId) && it.testnet == wallet.testnet } + }.filter { it.accountId.equalsAddress(wallet.accountId) && it.network == wallet.network } if (connections.isNotEmpty()) { val text = context.getString(Localization.disconnect_dapp_confirm, url.host) - SnackBarView.show(context, text) { disconnect(wallet, url, null) } + SnackBarView.show( + context = context, + text = text, + buttonText = context.getString(Localization.disconnect), + onClickListener = View.OnClickListener { + disconnect(wallet, url, null) + } + ) } } - suspend fun isScam(context: Context, wallet: WalletEntity, vararg uris: Uri): Boolean { - if (settingsRepository.isSafeModeEnabled(api) && safeModeClient.isHasScamUris(*uris)) { + override suspend fun isScam(context: Context, wallet: WalletEntity, vararg uris: Uri): Boolean { + if (settingsRepository.isSafeModeEnabled(wallet.network) && safeModeClient.isHasScamUris(*uris)) { withContext(Dispatchers.Main) { TonConnectSafeModeDialog(context).show(wallet) } @@ -421,15 +444,39 @@ class TonConnectManager( return fetchManifest(url) } - private suspend fun fetchManifest(url: String): AppEntity { + /** + * Shared http path with [fetchManifest], also used by the WalletKit `fetchManifest` callback. + * Fetches the manifest directly; on failure retries once via the c.tonapi.io proxy. If the + * proxy retry also fails the [ManifestException] propagates and the manifest-error toast shows. + */ + suspend fun fetchManifestRaw(url: String): String { val headers = ArrayMap().apply { set("Connection", "close") } - val response = api.defaultHttpClient.simple(url, headers) + return try { + httpGetManifest(url, headers) + } catch (ignored: Throwable) { + FirebaseHelper.manifestFetchRetry(url) + httpGetManifest(manifestProxyUrl(url), headers) + } + } + + // `.simple()` throws OkHttpError on any non-2xx; translate to ManifestException so the + // manifest-error catch in launchConnectFlow (and the wallet-kit callback) shows the toast. + private suspend fun httpGetManifest(url: String, headers: ArrayMap): String { + val response = try { + api.defaultHttpClient.simple(url, headers) + } catch (e: OkHttpError) { + throw ManifestException.NotFound(e.statusCode) + } if (response.code != 200) { throw ManifestException.NotFound(response.code) } - val body = response.body.string() + return response.readBody() + } + + private suspend fun fetchManifest(url: String): AppEntity { + val body = fetchManifestRaw(url) try { val app = AppEntity(body) dAppsRepository.insertApp(app) @@ -439,6 +486,18 @@ class TonConnectManager( } } + // Routes the manifest fetch through c.tonapi.io (CORS bypass / reachability). base64(url) + // matches the `btoa(url)` shape the proxy expects — Tonkeeper Web uses the same endpoint. + private fun manifestProxyUrl(url: String): String { + val encoded = Base64.encodeToString(url.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + return "https://c.tonapi.io/json?url=$encoded&no-cache=true" + } + + override fun createInjector( + bridge: DAppBridge, + wallet: WalletEntity? + ): ITonConnectWebViewInjector = TonConnectWebViewInjector(bridge) + companion object { private const val THROTTLE_DELAY_MS = 5000L @@ -452,7 +511,7 @@ class TonConnectManager( return uri.scheme?.lowercase() == "tc" || uri.path?.lowercase() == "/ton-connect" || uri.host?.lowercase() == "ton-connect" } - private fun normalizeUri(uri: Uri): Uri { + fun normalizeUri(uri: Uri): Uri { val value = uri.toString() for (prefix in othersPrefix) { if (value.startsWith(prefix, ignoreCase = true)) { @@ -463,4 +522,4 @@ class TonConnectManager( } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/Bridge.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/Bridge.kt index 55c8a4624..7c9f996d3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/Bridge.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/Bridge.kt @@ -1,6 +1,6 @@ package com.tonapps.tonkeeper.manager.tonconnect.bridge -import android.util.Log +import com.tonapps.log.L import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.base64.encodeBase64 import com.tonapps.blockchain.ton.connect.TONProof diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/JsonBuilder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/JsonBuilder.kt index cd710d41a..931549d2a 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/JsonBuilder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/JsonBuilder.kt @@ -5,7 +5,7 @@ import com.tonapps.blockchain.ton.extensions.hex import com.tonapps.blockchain.ton.connect.TONProof import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.BridgeError import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.SignDataRequestPayload -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.json.JSONArray import org.json.JSONObject @@ -146,7 +146,7 @@ internal object JsonBuilder { val json = JSONObject() json.put("name", "ton_addr") json.put("address", wallet.accountId) - json.put("network", (if (wallet.testnet) -3 else -239).toString()) + json.put("network", wallet.network.value.toString()) json.put("publicKey", wallet.publicKey.hex()) json.put("walletStateInit", stateInit) return json diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/BridgeEvent.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/BridgeEvent.kt index c0cf7aa6c..fb7a6159c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/BridgeEvent.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/BridgeEvent.kt @@ -12,14 +12,40 @@ data class BridgeEvent( val connection: AppConnectEntity, ) { + sealed interface Event + val method: BridgeMethod get() = message.method + + companion object { + fun parse(array: JSONArray): List { + val messages = mutableListOf() + for (i in 0 until array.length()) { + val obj = array.getJSONObject(i) + when { +// obj.has("traceId") -> messages.add(Trace(obj)) + obj.has("method") -> messages.add(Message(obj)) + } + } + + return messages + } + } + + data class Trace( + val traceId: String, + ) : Event { + constructor(json: JSONObject) : this( + json.getString("traceId"), + ) + } + data class Message( val method: BridgeMethod, val params: List, val id: Long, - ) { + ) : Event { constructor(json: JSONObject) : this( BridgeMethod.of(json.getString("method")), @@ -36,14 +62,6 @@ data class BridgeEvent( else -> listOf(params.toString()) } } - - fun parse(array: JSONArray): List { - val messages = mutableListOf() - for (i in 0 until array.length()) { - messages.add(Message(array.getJSONObject(i))) - } - return messages - } } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/SignDataRequestPayload.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/SignDataRequestPayload.kt index 941d2256b..6f992db64 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/SignDataRequestPayload.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tonconnect/bridge/model/SignDataRequestPayload.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.manager.tonconnect.bridge.model import android.os.Parcelable -import android.util.Log +import com.tonapps.log.L import com.tonapps.base64.decodeBase64 import com.tonapps.base64.encodeBase64 import com.tonapps.blockchain.ton.extensions.cellFromBase64 @@ -25,9 +25,7 @@ abstract class SignDataRequestPayload(val type: String): Parcelable { return try { parse(JSONObject(value)) } catch (e: Throwable) { - if (DevSettings.tonConnectLogs) { - Log.d("TonConnect", "Failed to parse SignDataRequestPayload: $value", e) - } + L.e("TonConnect", "Failed to parse SignDataRequestPayload: $value", e) null } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/BaseTransactionManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/BaseTransactionManager.kt deleted file mode 100644 index 939cb9c15..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/BaseTransactionManager.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.tonapps.tonkeeper.manager.tx - -import com.tonapps.extensions.MutableEffectFlow -import com.tonapps.tonkeeper.manager.tx.model.PendingHash -import com.tonapps.tonkeeper.manager.tx.model.PendingWrapEvent -import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.withRetry -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.withContext - -open class BaseTransactionManager( - private val api: API -) { - - val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - - private val _pendingHashFlow = MutableEffectFlow() - private val pendingTxFlow = _pendingHashFlow.mapNotNull(::fetchPendingTx).flowOn(Dispatchers.IO) - - - fun addPendingHash(accountId: String, testnet: Boolean, hash: String) { - _pendingHashFlow.tryEmit(PendingHash(accountId, testnet, hash)) - } - - private suspend fun fetchTx( - accountId: String, - testnet: Boolean, - hash: String - ) = withContext(Dispatchers.IO) { - withRetry { - api.accounts(testnet).getAccountEvent(accountId, hash) - } - } - - private suspend fun fetchPendingTx(hash: PendingHash): PendingWrapEvent? { - val event = fetchTx( - accountId = hash.accountId, - testnet = hash.testnet, - hash = hash.hash - ) ?: return null - return PendingWrapEvent(hash, event) - } - - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/TransactionManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/TransactionManager.kt deleted file mode 100644 index f75f09d9f..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/TransactionManager.kt +++ /dev/null @@ -1,204 +0,0 @@ -package com.tonapps.tonkeeper.manager.tx - -import android.util.Log -import com.tonapps.blockchain.ton.extensions.base64 -import com.tonapps.extensions.MutableEffectFlow -import com.tonapps.tonkeeper.App -import com.tonapps.tonkeeper.worker.WidgetUpdaterWorker -import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.SendBlockchainState -import com.tonapps.wallet.api.entity.AccountEventEntity -import com.tonapps.wallet.api.entity.ConfigEntity -import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.battery.BatteryRepository -import com.tonapps.wallet.data.settings.SettingsRepository -import com.tonapps.wallet.data.token.TokenRepository -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.ton.bitstring.BitString -import org.ton.cell.Cell -import kotlin.time.Duration.Companion.seconds - -@OptIn(ExperimentalCoroutinesApi::class) -class TransactionManager( - private val accountRepository: AccountRepository, - private val api: API, - private val batteryRepository: BatteryRepository, - private val tokenRepository: TokenRepository, - private val settingsRepository: SettingsRepository, -): BaseTransactionManager(api) { - - private val _sendingTransactionFlow = MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val sendingTransactionFlow = _sendingTransactionFlow.asSharedFlow() - - private val _transactionFlow = MutableSharedFlow( - replay = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val transactionFlow = _transactionFlow.asSharedFlow() - - private val _tronUpdatedFlow = MutableEffectFlow() - val tronUpdatedFlow = _tronUpdatedFlow.asSharedFlow() - - private var tronRefreshJob: Job? = null - - init { - _tronUpdatedFlow.tryEmit(Unit) - sendingTransactionFlow.mapNotNull { getTransaction(it.wallet, it.hash) } - .onEach { transaction -> - _transactionFlow.tryEmit(transaction) - }.launchIn(scope) - - combine( - api.configFlow.filter { !it.empty }, - accountRepository.selectedWalletFlow - ) { config, wallet -> - realtime(config, wallet) - }.flatMapLatest { it }.filterNotNull().onEach { transaction -> - _transactionFlow.tryEmit(transaction) - }.launchIn(scope) - - sendingTransactionFlow.onEach { - delay(5000) - WidgetUpdaterWorker.update(App.instance) - }.launchIn(scope) - - combine( - accountRepository.selectedWalletFlow, - tronUpdatedFlow, - settingsRepository.tokenPrefsChangedFlow - ) { wallet, _, _ -> - val tronEnabled = settingsRepository.getTronUsdtEnabled(wallet.id) - val tronAddress = accountRepository.getTronAddress(wallet.id) - if (tronEnabled && tronAddress != null && wallet.hasPrivateKey && !wallet.testnet && !api.config.flags.disableBattery) { - Pair(wallet, tronAddress) - } else { - null - } - }.filterNotNull().onEach { (wallet, tronAddress) -> - tronRefreshJob?.cancel() - tronRefreshJob = scope.launch { - delay(30.seconds) - tokenRepository.refreshTron(wallet.accountId, wallet.testnet, tronAddress) - - _tronUpdatedFlow.tryEmit(Unit) - } - }.launchIn(scope) - } - - fun eventsFlow(wallet: WalletEntity) = transactionFlow.filter { - it.accountId == wallet.accountId && it.testnet == wallet.testnet - } - - private fun realtime(config: ConfigEntity, wallet: WalletEntity) = api.realtime( - accountId = wallet.accountId, - testnet = wallet.testnet, - config = config, - onFailure = null - ).map { it.data }.map { getTransaction(wallet, it) } - - private suspend fun getTransaction( - wallet: WalletEntity, - hash: String - ): AccountEventEntity? = withContext(Dispatchers.IO) { - api.getTransactionByHash(wallet.accountId, wallet.testnet, hash) - } - - private suspend fun sendWithBattery( - wallet: WalletEntity, - boc: String, - source: String, - confirmationTime: Double, - ): SendBlockchainState { - val tonProofToken = accountRepository.requestTonProofToken(wallet) - ?: return SendBlockchainState.UNKNOWN_ERROR - val state = api.sendToBlockchainWithBattery( - boc = boc, - tonProofToken = tonProofToken, - testnet = wallet.testnet, - source = source, - confirmationTime = confirmationTime - ) - if (state == SendBlockchainState.SUCCESS) { - batteryRepository.refreshBalanceDelay( - publicKey = wallet.publicKey, - tonProofToken = tonProofToken, - testnet = wallet.testnet, - ) - } - return state - } - - suspend fun send( - wallet: WalletEntity, - boc: String, - withBattery: Boolean, - source: String, - normalizedHash: BitString, - confirmationTime: Double, - ) = send(wallet, boc, withBattery, source, confirmationTime, normalizedHash, 0) - - private suspend fun send( - wallet: WalletEntity, - boc: String, - withBattery: Boolean, - source: String, - confirmationTime: Double, - normalizedHash: BitString, - attempt: Int - ): SendBlockchainState { - val state = if (withBattery) { - sendWithBattery(wallet, boc, source, confirmationTime) - } else { - api.sendToBlockchain(boc, wallet.testnet, source, confirmationTime) - } - if (state == SendBlockchainState.SUCCESS) { - // addPendingHash(wallet.accountId, wallet.testnet, normalizedHash.toHex()) - _sendingTransactionFlow.tryEmit(SendingTransaction(wallet.copy(), boc)) - return state - } - - return if (attempt > 3) { - state - } else { - delay(10.seconds) - send(wallet, boc, withBattery, source, confirmationTime, normalizedHash,attempt + 1) - } - } - - suspend fun send( - wallet: WalletEntity, - boc: Cell, - withBattery: Boolean, - source: String, - confirmationTime: Double, - ) = send( - wallet = wallet, - boc = boc.base64(), - withBattery = withBattery, - source = source, - normalizedHash = wallet.contract.normalizedHashFromSignedBody(boc) ?: boc.hash(), - confirmationTime = confirmationTime - ) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/model/PendingHash.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/model/PendingHash.kt deleted file mode 100644 index 3c7fdf137..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/model/PendingHash.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.tonapps.tonkeeper.manager.tx.model - -data class PendingHash( - val accountId: String, - val testnet: Boolean, - val hash: String -) \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/model/PendingWrapEvent.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/model/PendingWrapEvent.kt deleted file mode 100644 index 0ee98df50..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/tx/model/PendingWrapEvent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.tonapps.tonkeeper.manager.tx.model - -import io.tonapi.models.AccountEvent - -data class PendingWrapEvent( - val hash: PendingHash, - val event: AccountEvent -) \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/TonkeeperSessionManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/TonkeeperSessionManager.kt new file mode 100644 index 000000000..23824ec0b --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/TonkeeperSessionManager.kt @@ -0,0 +1,162 @@ +package com.tonapps.tonkeeper.manager.walletkit + +import com.tonapps.log.L +import androidx.core.net.toUri +import com.tonapps.security.CryptoBox +import com.tonapps.security.hex +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.wallet.data.dapps.DAppsRepository +import com.tonapps.wallet.data.dapps.entities.AppConnectEntity +import com.tonapps.wallet.data.dapps.entities.AppEntity +import io.ton.walletkit.api.generated.TONDAppInfo +import io.ton.walletkit.model.TONUserFriendlyAddress +import io.ton.walletkit.session.SessionFilter +import io.ton.walletkit.session.TONConnectSession +import io.ton.walletkit.session.TONConnectSessionManager +import java.time.Instant + +internal fun normalizedHost(urlOrDomain: String): String { + return try { + java.net.URL(urlOrDomain).host + ?.lowercase() + ?: "" + } catch (_: Exception) { + urlOrDomain.lowercase() + } +} + +/** + * Session manager that bridges the SDK to Tonkeeper's database. + */ +class TonkeeperSessionManager( + private val accountRepository: AccountRepository, + private val dAppsRepository: DAppsRepository, +) : TONConnectSessionManager { + + override suspend fun createSession( + sessionId: String, + dAppInfo: TONDAppInfo, + walletId: String, + walletAddress: String, + isJsBridge: Boolean, + ): TONConnectSession { + val wallet = accountRepository.getWallets().find { it.id == walletId } + ?: throw IllegalArgumentException("Wallet not found: $walletId") + + val keyPair = CryptoBox.keyPair() + val appUrl = (dAppInfo.url ?: "").removeSuffix("/").toUri() + + val connection = AppConnectEntity( + accountId = wallet.accountId, + network = wallet.network, + clientId = sessionId, + type = if (isJsBridge) AppConnectEntity.Type.Internal else AppConnectEntity.Type.External, + appUrl = appUrl, + keyPair = keyPair, + proofSignature = null, + proofPayload = null, + pushEnabled = false + ) + + if (dAppsRepository.newConnect(connection)) { + return SessionHelper.buildSession( + connection = connection, + walletId = walletId, + walletAddress = walletAddress, + dAppInfo = dAppInfo, + ) + } else { + throw UnsupportedOperationException("Failed to create connections for session") + } + } + + override suspend fun getSession(sessionId: String): TONConnectSession? { + val connection = dAppsRepository.getConnections().find { it.clientId == sessionId } ?: return null + val wallet = accountRepository.getWallets().find { + it.accountId == connection.accountId && it.network == connection.network + } ?: return null + val app = dAppsRepository.getApp(connection.appUrl) + val dApp = TONDAppInfo(app.name, null, app.url.toString(), app.iconUrl.toString(), null) + + return runCatching { + SessionHelper.buildSession(connection, wallet.id, wallet.address, dApp) + }.onFailure { L.e(it, "Failed to build session for $sessionId") }.getOrNull() + } + + override suspend fun getSessions(filter: SessionFilter?): List { + val wallets = accountRepository.getWallets() + val allConnections = dAppsRepository.getConnections() + val filterDomainHost = filter?.domain?.let { normalizedHost(it) } + + return allConnections.mapNotNull { connection -> + val wallet = wallets.find { + it.accountId == connection.accountId && it.network == connection.network + } ?: return@mapNotNull null + + if (filter?.walletId != null && filter.walletId != wallet.id) return@mapNotNull null // + + if (filterDomainHost != null) { + val connDomain = normalizedHost(connection.appUrl.toString()) + if (connDomain != filterDomainHost) return@mapNotNull null + } + + if (filter?.isJsBridge != null) { + val connectionIsJsBridge = connection.type == AppConnectEntity.Type.Internal + if (connectionIsJsBridge != filter.isJsBridge) return@mapNotNull null + } + + runCatching { + SessionHelper.buildSession(connection, wallet.id, wallet.address) + }.onFailure { + L.e(it, "Failed to build session for connection ${connection.clientId}") + }.getOrNull() + } + } + + override suspend fun removeSession(sessionId: String) { + val connection = dAppsRepository.getConnections().find { it.clientId == sessionId } ?: return + dAppsRepository.deleteConnect(connection) + } + + override suspend fun removeSessions(filter: SessionFilter?) { + val sessionIds = getSessions(filter).map { it.sessionId }.toSet() + dAppsRepository.getConnections() + .filter { it.clientId in sessionIds } + .forEach { dAppsRepository.deleteConnect(it) } + } + + override suspend fun clearSessions() { + val connections = dAppsRepository.getConnections() + for (connection in connections) { + dAppsRepository.deleteConnect(connection) + } + } + + private object SessionHelper { + private const val SCHEMA_VERSION = 1 + + fun buildSession( + connection: AppConnectEntity, + walletId: String, + walletAddress: String, + dAppInfo: TONDAppInfo? = null, + ): TONConnectSession { + return TONConnectSession( + sessionId = connection.clientId, + walletId = walletId, + walletAddress = TONUserFriendlyAddress(walletAddress), + createdAt = Instant.ofEpochMilli(connection.timestamp).toString(), + lastActivityAt = Instant.now().toString(), + privateKey = hex(connection.keyPair.privateKey), + publicKey = hex(connection.keyPair.publicKey), + domain = normalizedHost(connection.appUrl.toString()), + dAppName = dAppInfo?.name, + dAppDescription = dAppInfo?.description, + dAppUrl = dAppInfo?.url ?: connection.appUrl.toString(), + dAppIconUrl = dAppInfo?.iconUrl, + isJsBridge = connection.type == AppConnectEntity.Type.Internal, + schemaVersion = SCHEMA_VERSION, + ) + } + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/TonkeeperWalletAdapter.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/TonkeeperWalletAdapter.kt new file mode 100644 index 000000000..1c621ebbb --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/TonkeeperWalletAdapter.kt @@ -0,0 +1,87 @@ +package com.tonapps.tonkeeper.manager.walletkit + +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.walletkit.add0x +import com.tonapps.blockchain.ton.TonNetwork +import io.ton.walletkit.api.ChainIds +import io.ton.walletkit.api.generated.TONNetwork +import io.ton.walletkit.api.generated.TONPreparedSignData +import io.ton.walletkit.api.generated.TONProofMessage +import io.ton.walletkit.api.generated.TONTransactionRequest +import io.ton.walletkit.config.TONWalletKitConfiguration +import io.ton.walletkit.config.SignDataType +import io.ton.walletkit.model.TONBase64 +import io.ton.walletkit.model.TONHex +import io.ton.walletkit.model.TONUserFriendlyAddress +import io.ton.walletkit.model.TONWalletAdapter +import org.ton.crypto.hex + +/** + * TONWalletAdapter implementation that wraps WalletEntity. + */ +class TonkeeperWalletAdapter( + private val wallet: WalletEntity, +) : TONWalletAdapter { + + override fun identifier(): String = wallet.id + + override fun publicKey(): TONHex { + val publicKeyBytes = wallet.publicKey.key.toByteArray() + return TONHex(hex(publicKeyBytes).add0x()) + } + + override fun network(): TONNetwork { + return TONNetwork(wallet.network.value.toString()) + } + + override fun address(testnet: Boolean): TONUserFriendlyAddress { + return TONUserFriendlyAddress(wallet.address) + } + + override suspend fun stateInit(): TONBase64 { + val stateInitCell = wallet.contract.stateInitCell() + val bocBytes = org.ton.boc.BagOfCells(stateInitCell).toByteArray() + return TONBase64.fromData(bocBytes) + } + + override suspend fun signedSendTransaction( + input: TONTransactionRequest, + fakeSignature: Boolean?, + ): TONBase64 { + throw UnsupportedOperationException("Transaction signing handled by Tonkeeper flow") + } + + override suspend fun signedSignMessage( + input: TONTransactionRequest, + fakeSignature: Boolean?, + ): TONBase64 { + throw UnsupportedOperationException("Message signing handled by Tonkeeper flow") + } + + override suspend fun signedSignData( + input: TONPreparedSignData, + fakeSignature: Boolean?, + ): TONHex { + throw UnsupportedOperationException("Data signing handled by Tonkeeper flow") + } + + override suspend fun signedTonProof( + input: TONProofMessage, + fakeSignature: Boolean?, + ): TONHex { + throw UnsupportedOperationException("Proof signing handled by Tonkeeper flow") + } + + override fun supportedFeatures(): List { + return listOf( + TONWalletKitConfiguration.SendTransactionFeature(maxMessages = wallet.maxMessages), + TONWalletKitConfiguration.SignDataFeature( + types = listOf( + SignDataType.TEXT, + SignDataType.BINARY, + SignDataType.CELL, + ), + ), + ) + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitEvent.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitEvent.kt new file mode 100644 index 000000000..fb6ba40ab --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitEvent.kt @@ -0,0 +1,27 @@ +package com.tonapps.tonkeeper.manager.walletkit + +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.wallet.data.dapps.entities.AppConnectEntity +import io.ton.walletkit.request.TONWalletSignDataRequest +import io.ton.walletkit.request.TONWalletTransactionRequest +import io.ton.walletkit.request.TONWalletConnectionRequest + +sealed class WalletKitEvent( + open val wallet: WalletEntity?, +) { + + data class SendTransactionRequest( + val request: TONWalletTransactionRequest, + override val wallet: WalletEntity, + ) : WalletKitEvent(wallet) + + data class SignDataRequest( + val request: TONWalletSignDataRequest, + override val wallet: WalletEntity, + ) : WalletKitEvent(wallet) + + data class ConnectionRequest( + val request: TONWalletConnectionRequest, + var fromPackageName: String? + ) : WalletKitEvent(null) +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitTonConnect.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitTonConnect.kt new file mode 100644 index 000000000..89d9da5ff --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitTonConnect.kt @@ -0,0 +1,458 @@ +package com.tonapps.tonkeeper.manager.walletkit + +import android.content.Context +import android.net.Uri +import androidx.core.net.toUri +import com.tonapps.log.L +import com.tonapps.base64.decodeBase64 +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.blockchain.ton.connect.TONProof +import com.tonapps.extensions.hasQuery +import com.tonapps.extensions.isEmptyQuery +import com.tonapps.extensions.withoutQuery +import com.tonapps.core.flags.WalletFeature +import com.tonapps.tonkeeper.extensions.showToast +import com.tonapps.tonkeeper.manager.push.PushManager +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge +import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectWebViewInjector +import com.tonapps.tonkeeper.manager.tonconnect.TonConnect +import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager.Companion.normalizeUri +import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.BridgeError +import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.SignDataRequestPayload +import com.tonapps.tonkeeper.manager.tonconnect.exceptions.ManifestException +import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppBridge +import com.tonapps.wallet.api.API +import com.tonapps.wallet.data.account.AccountRepository +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.core.deeplink.DeepLinkRoute +import com.tonapps.security.Security +import com.tonapps.wallet.data.dapps.DAppsRepository +import com.tonapps.wallet.data.dapps.entities.AppConnectEntity +import com.tonapps.wallet.data.dapps.entities.AppEntity +import com.tonapps.wallet.localization.Localization +import io.ton.walletkit.ITONWalletKit +import io.ton.walletkit.api.MAINNET +import io.ton.walletkit.api.TESTNET +import io.ton.walletkit.api.generated.TONConnectEventErrorCodes +import io.ton.walletkit.api.generated.TONConnectionApprovalProof +import io.ton.walletkit.api.generated.TONConnectionApprovalProofDomain +import io.ton.walletkit.api.generated.TONConnectionApprovalResponse +import io.ton.walletkit.api.generated.TONManifestFetchResult +import io.ton.walletkit.api.generated.TONNetwork +import io.ton.walletkit.config.TONWalletKitConfiguration +import io.ton.walletkit.config.SignDataType +import io.ton.walletkit.event.TONWalletKitEvent +import io.ton.walletkit.listener.TONBridgeEventsHandler +import io.ton.walletkit.model.TONBase64 +import io.ton.walletkit.request.TONWalletConnectionRequest +import io.ton.walletkit.storage.TONWalletKitStorageType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +class WalletKitTonConnect( + private val context: Context, + private val scope: CoroutineScope, + private val accountRepository: AccountRepository, + private val dAppsRepository: DAppsRepository, + private val pushManager: PushManager, + private val manager: TonConnectManager, +) : ITonConnectBridge by manager { + + @Volatile + private var walletKit: ITONWalletKit? = null + private val _initialized = MutableStateFlow(false) + + private val _walletKitEventFlow = MutableSharedFlow() + override val walletKitEventFlow: Flow = + _walletKitEventFlow.asSharedFlow() + + private val initMutex = Mutex() + private val syncMutex = Mutex() + private var walletObserverJob: Job? = null + + fun getWalletKitInstance(): ITONWalletKit? = walletKit + + suspend fun initialize() { + if (walletKit != null) return + + initMutex.withLock { + if (walletKit != null) return + + val sessionManager = TonkeeperSessionManager(accountRepository, dAppsRepository) + val config = TONWalletKitConfiguration( + networkConfigurations = setOf( + TONWalletKitConfiguration.NetworkConfiguration( + network = TONNetwork.MAINNET, + apiClientConfiguration = TONWalletKitConfiguration.APIClientConfiguration(key = ""), + ), + TONWalletKitConfiguration.NetworkConfiguration( + network = TONNetwork.TESTNET, + apiClientConfiguration = TONWalletKitConfiguration.APIClientConfiguration(key = ""), + ), + TONWalletKitConfiguration.NetworkConfiguration( + network = TONNetwork(TonNetwork.TETRA.value.toString()), + apiClientConfiguration = TONWalletKitConfiguration.APIClientConfiguration(key = ""), + ), + ), + walletManifest = TONWalletKitConfiguration.Manifest( + name = MANIFEST_NAME, + appName = MANIFEST_APP_NAME, + imageUrl = MANIFEST_IMAGE_URL, + aboutUrl = MANIFEST_ABOUT_URL, + universalLink = MANIFEST_UNIVERSAL_LINK, + bridgeUrl = BRIDGE_URL, + jsBridgeKey = MANIFEST_JS_BRIDGE_KEY, + ), + bridge = TONWalletKitConfiguration.Bridge(bridgeUrl = BRIDGE_URL), + features = listOf( + TONWalletKitConfiguration.SendTransactionFeature(maxMessages = 255), + TONWalletKitConfiguration.SignDataFeature( + types = listOf(SignDataType.TEXT, SignDataType.BINARY, SignDataType.CELL), + ), + ), + storageType = TONWalletKitStorageType.Encrypted, + sessionManager = sessionManager, + eventsConfiguration = TONWalletKitConfiguration.EventsConfiguration( + disableTransactionEmulation = true, + ), + fetchManifest = ::fetchManifest, + ) + + val walletKit = ITONWalletKit.initialize(context, config) + walletKit.addEventsHandler(object : TONBridgeEventsHandler { + override fun handle(event: TONWalletKitEvent) { + handleWalletKitEvent(event) + } + }) + + this.walletKit = walletKit + syncWalletList(walletKit) + observeWalletChanges() + _initialized.value = true + } + } + + private suspend fun fetchManifest(url: String): TONManifestFetchResult { + return try { + val body = manager.fetchManifestRaw(url) + TONManifestFetchResult(manifest = Json.parseToJsonElement(body)) + } catch (e: ManifestException.NotFound) { + TONManifestFetchResult( + manifest = null, + manifestFetchErrorCode = TONConnectEventErrorCodes.manifestNotFoundError, + ) + } catch (e: Throwable) { + L.w(e, "fetchManifest failed for $url") + TONManifestFetchResult( + manifest = null, + manifestFetchErrorCode = TONConnectEventErrorCodes.manifestContentError, + ) + } + } + + private fun observeWalletChanges() { + walletObserverJob?.cancel() + walletObserverJob = accountRepository.selectedStateFlow + .onEach { state -> + when (state) { + is AccountRepository.SelectedState.Wallet, + is AccountRepository.SelectedState.Empty -> { + val kit = walletKit ?: return@onEach + syncWalletList(kit) + } + is AccountRepository.SelectedState.Initialization -> {} + } + } + .launchIn(scope) + } + + private fun handleWalletKitEvent(event: TONWalletKitEvent) { + scope.launch(Dispatchers.IO) { + try { + when (event) { + is TONWalletKitEvent.ConnectRequest -> { + // Handled only in case connection event from web view + // Deeplink is handled directly to process additional parameters + // For example - needed to handle fromPackageNameParameter + _walletKitEventFlow.emit(WalletKitEvent.ConnectionRequest(request = event.request, fromPackageName = null)) + } + is TONWalletKitEvent.SendTransactionRequest -> { + val request = event.request + val wallet = accountRepository.getWallets().find { it.id == request.event.walletId } + if (wallet == null) { + L.w("Wallet not found for id: ${request.event.walletId}") + request.reject("Wallet not found", UNKNOWN_APP_ERROR) + return@launch + } + _walletKitEventFlow.emit(WalletKitEvent.SendTransactionRequest(request = request, wallet = wallet)) + } + is TONWalletKitEvent.SignDataRequest -> { + val request = event.request + val wallet = accountRepository.getWallets().find { it.id == request.event.walletId } + if (wallet == null) { + L.w("Wallet not found for id: ${request.event.walletId}") + request.reject("Wallet not found", UNKNOWN_APP_ERROR) + return@launch + } + _walletKitEventFlow.emit(WalletKitEvent.SignDataRequest(request = request, wallet = wallet)) + } + is TONWalletKitEvent.SignMessageRequest -> { + // Sign-message (sign-only) isn't wired into the Tonkeeper flow yet + // (TonkeeperWalletAdapter.signedSignMessage is unsupported), so decline + // cleanly instead of silently dropping the request. + L.w("SignMessageRequest received but not supported") + event.request.reject("Sign message is not supported") + } + is TONWalletKitEvent.Disconnect -> { + // No need to additionally handle this event, because + // connection will be automatically deleted + // in session manager and dapps list will be + // notified and updated using connections flow + } + is TONWalletKitEvent.RequestError -> { + L.e("Request error: ${event.event}") + } + } + } catch (e: Exception) { + L.e(e, "Error handling WalletKit event") + } + } + } + + override fun connectBridge() { + scope.launch(Dispatchers.IO) { + try { + initialize() + } catch (e: Exception) { + L.e(e, "Failed to initialize WalletKit on connectBridge") + } + } + } + + override fun disconnectBridge() {} + + override fun reconnectBridge() { + connectBridge() + } + + override fun disconnect(wallet: WalletEntity, appUrl: Uri, type: AppConnectEntity.Type?) { + scope.launch(Dispatchers.IO) { + val connections = dAppsRepository.getConnections().filter { connection -> + connection.accountId == wallet.accountId && + connection.network == wallet.network && + connection.appUrl.withoutQuery == appUrl.withoutQuery && + (type == null || connection.type == type) + } + if (connections.isEmpty()) return@launch + + val kit = walletKit + if (kit != null) { + for (connection in connections) { + try { + kit.disconnectSession(connection.clientId) + } catch (e: Exception) { + L.w(e, "Failed to disconnect session ${connection.clientId}") + } + } + } + dAppsRepository.deleteApp(wallet.accountId, wallet.network, appUrl, type) + pushManager.dAppUnsubscribe(wallet, connections) + } + } + + private suspend fun syncWalletList(kit: ITONWalletKit) { + syncMutex.withLock { + try { + val wallets = accountRepository.getWallets().associateBy { it.id } + val loadedIds = kit.getWallets().map { it.id }.toSet() + + for (id in loadedIds) { + if (id !in wallets) { + try { + kit.removeWallet(id) + } catch (e: Exception) { + L.w(e, "Failed to remove stale wallet $id from kit") + } + } + } + + for (wallet in wallets.values) { + if (wallet.id in loadedIds) continue + try { + kit.addWallet(TonkeeperWalletAdapter(wallet)) + } catch (e: Exception) { + L.e(e, "Failed to load wallet ${wallet.address}") + } + } + } catch (e: Exception) { + L.e(e, "Failed to sync wallets") + } + } + } + + override fun processDeeplink( + context: Context, + uri: Uri, + fromQR: Boolean, + refSource: Uri?, + fromPackageName: String? + ): Uri? { + val returnUri = TonConnect.parseReturn(uri.getQueryParameter("ret"), refSource) + + try { + val route = DeepLinkRoute.resolve(uri) + if (route !is DeepLinkRoute.TonConnect) return null + + val normalizedUri = normalizeUri(uri) + + scope.launch { + initialize() + + val tonConnectionRequest = walletKit?.connectionEventFromUrl(normalizedUri.toString()) + val dApp = tonConnectionRequest?.event?.dAppInfo + val manifestUrl = dApp?.manifestUrl + + if (dApp == null || manifestUrl == null) { + return@launch + } + + if (!isScam( + context, + WalletEntity.EMPTY, + uri, + normalizedUri, + manifestUrl.toUri() + ) + ) { + val url = dApp.url + val name = dApp.name + val iconUrl = dApp.iconUrl + + if (url != null && name != null && iconUrl != null) { + try { + dAppsRepository.insertApp(AppEntity(url = url.toUri(), name = name, iconUrl = iconUrl, empty = false)) + initialize() + // TODO: fix the production bug with fromPackageName = null + _walletKitEventFlow.emit(WalletKitEvent.ConnectionRequest(request = tonConnectionRequest, fromPackageName = fromPackageName)) + } catch (e: Exception) { + L.w(e, "Failed to insert app before connect approval") + } + } + } + } + } catch (e: Exception) { + if (uri.isEmptyQuery || uri.hasQuery("open") || uri.hasQuery("ret")) { + return returnUri + } else { + context.showToast(Localization.invalid_link) + return null + } + } + return returnUri + } + + override suspend fun sendTransactionResponseSuccess(connection: AppConnectEntity, boc: String, id: Long) {} + + override suspend fun sendBridgeError(connection: AppConnectEntity, error: BridgeError, id: Long) {} + + override suspend fun sendSignDataResponseSuccess( + connection: AppConnectEntity, + proof: TONProof.Result, + address: String, + payload: SignDataRequestPayload, + id: Long + ) {} + + override suspend fun approveConnectionRequest( + request: TONWalletConnectionRequest, + wallet: WalletEntity, + proof: TONProof.Result?, + pushEnabled: Boolean + ): Boolean { + return try { + val kitWallet = walletKit?.getWallet(wallet.id) ?: return false + val response = proof?.let { convertProofToResponse(it) } + val from = request.event.from?.takeIf { it.isNotBlank() } ?: Security.randomBytes(32).toHexString() + request.event.from = from + request.approve(kitWallet, response) + + var connection = dAppsRepository + .getConnections() + .first { it.clientId == from } + + connection = connection.copy( + proofSignature = proof?.signature, + proofPayload = proof?.payload, + timestamp = proof?.timestamp ?: connection.timestamp, + pushEnabled = pushEnabled + ) + + dAppsRepository.newConnect(connection) + dAppsRepository.setPushEnabled(connection.accountId, connection.network, connection.appUrl, pushEnabled) + true + } catch (e: Exception) { + L.e(e, "Failed to approve connection") + false + } + } + + override suspend fun rejectConnectionRequest( + request: TONWalletConnectionRequest, + reason: String? + ): Boolean { + return try { + request.reject(reason) + true + } catch (e: Exception) { + L.e(e, "Failed to reject connection") + false + } + } + + private fun convertProofToResponse(proof: TONProof.Result): TONConnectionApprovalResponse { + val signatureBytes = proof.signature.decodeBase64() + return TONConnectionApprovalResponse( + proof = TONConnectionApprovalProof( + signature = TONBase64.fromData(signatureBytes), + timestamp = proof.timestamp.toDouble(), + domain = TONConnectionApprovalProofDomain( + lengthBytes = proof.domain.value.length, + value = proof.domain.value + ), + payload = proof.payload ?: "" + ) + ) + } + + override fun createInjector(bridge: DAppBridge, wallet: WalletEntity?): ITonConnectWebViewInjector = + WalletKitWebViewInjector(walletKitBridge = this, wallet) + + companion object { + private const val UNKNOWN_APP_ERROR = 100 + + private const val MANIFEST_NAME = "tonkeeper" + private const val MANIFEST_APP_NAME = "Tonkeeper" + private const val MANIFEST_JS_BRIDGE_KEY = "tonkeeper" + private const val MANIFEST_IMAGE_URL = "https://tonkeeper.com/assets/tonkeeper-logo.png" + private const val MANIFEST_ABOUT_URL = "https://tonkeeper.com" + private const val MANIFEST_UNIVERSAL_LINK = "https://app.tonkeeper.com/ton-connect" + private const val BRIDGE_URL = "https://bridge.tonapi.io/bridge" + + fun isEnabled(api: API): Boolean { + return WalletFeature.WalletKitEnabled.isEnabled || !api.getConfig(TonNetwork.MAINNET).flags.disableWalletKit + } + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitWebViewInjector.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitWebViewInjector.kt new file mode 100644 index 000000000..10c2497ce --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/walletkit/WalletKitWebViewInjector.kt @@ -0,0 +1,22 @@ +package com.tonapps.tonkeeper.manager.walletkit + +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectWebViewInjector +import com.tonapps.tonkeeper.ui.component.TonConnectWebView +import com.tonapps.blockchain.model.legacy.WalletEntity +import io.ton.walletkit.extensions.injectTonConnect + +/** + * WalletKit TonConnect injector using the WalletKit SDK. + */ +class WalletKitWebViewInjector( + private val walletKitBridge: WalletKitTonConnect, + private val wallet: WalletEntity? = null, +) : ITonConnectWebViewInjector { + + override fun inject(webView: TonConnectWebView): Boolean { + val instance = walletKitBridge.getWalletKitInstance() ?: return false + val walletId = wallet?.id + webView.injectTonConnect(instance, walletId) + return true + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetManager.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetManager.kt index ad7fd1af7..77c9b38ce 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetManager.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetManager.kt @@ -9,7 +9,6 @@ import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle -import android.util.Log import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.extensions.getParcelableCompat import com.tonapps.extensions.whileTimeoutOrNull @@ -63,7 +62,7 @@ object WidgetManager { } val isRequestPinAppWidgetSupported: Boolean - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && appWidgetManager.isRequestPinAppWidgetSupported + get() = appWidgetManager.isRequestPinAppWidgetSupported fun installBalance(activity: Activity, walletId: String, jettonAddress: String? = null) { val params = WidgetParams.Balance(walletId, jettonAddress) @@ -215,4 +214,4 @@ object WidgetManager { context.sendBroadcast(intent) }*/ -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetSettings.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetSettings.kt index 30a148501..e7a91506c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetSettings.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/manager/widget/WidgetSettings.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.manager.widget import android.content.Context import android.content.SharedPreferences -import android.util.Log +import com.tonapps.log.L import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.extensions.constructor import com.tonapps.extensions.string diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AndroidCurrency.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AndroidCurrency.kt index 500677334..f802667ce 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AndroidCurrency.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AndroidCurrency.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.os import android.icu.util.Currency -import com.tonapps.uikit.flag.R +import com.tonapps.ui.uikit.flag.R import com.tonapps.uikit.flag.getFlagDrawable import java.util.Locale diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AppInstall.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AppInstall.kt index b735917d3..cde005342 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AppInstall.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/AppInstall.kt @@ -7,6 +7,7 @@ object AppInstall { enum class Source(val packageName: String, val title: String) { GOOGLE_PLAY("com.android.vending", "GooglePlay"), + AURORA_STORE("com.aurora.store", "AuroraStore"), UNKNOWN("unknown", "APK") } @@ -18,7 +19,7 @@ object AppInstall { } } - private fun getInstallerPackageName(context: Context): String? { + fun getInstallerPackageName(context: Context): String? { try { val packageManager = context.packageManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/DeviceCountry.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/DeviceCountry.kt index 4b083e4ac..c0db42acb 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/DeviceCountry.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/os/DeviceCountry.kt @@ -1,8 +1,11 @@ package com.tonapps.tonkeeper.os import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities import android.telephony.TelephonyManager import java.util.Locale +import java.util.TimeZone object DeviceCountry { @@ -20,6 +23,26 @@ object DeviceCountry { return country.uppercase() } + fun timeZone(): String { + return TimeZone.getDefault().id + } + + fun isUsingVpn(context: Context): Boolean { + try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val activeNetwork = connectivityManager.activeNetwork + ?: return false + + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + ?: return false + + return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) + } catch (e: Throwable) { + return false + } + } + fun fromNetwork(context: Context) = try { val manager = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager manager.networkCountryIso.ifEmpty { null } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseHolderWalletScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseHolderWalletScreen.kt index e005fcd28..7e7992d90 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseHolderWalletScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseHolderWalletScreen.kt @@ -5,7 +5,7 @@ import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import com.tonapps.tonkeeper.App -import com.tonapps.tonkeeper.RemoteConfig +import com.tonapps.core.flags.RemoteConfig import com.tonapps.tonkeeper.koin.remoteConfig import com.tonapps.tonkeeper.koin.serverConfig import com.tonapps.tonkeeperx.R @@ -81,9 +81,6 @@ abstract class BaseHolderWalletScreen( val serverConfig: ConfigEntity? get() = context?.serverConfig - val remoteConfig: RemoteConfig? - get() = context?.remoteConfig - @Suppress("UNCHECKED_CAST") override val primaryFragment: P get() = requireParentFragment() as P diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseListWalletScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseListWalletScreen.kt index 12965080e..829a74bae 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseListWalletScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseListWalletScreen.kt @@ -1,7 +1,6 @@ package com.tonapps.tonkeeper.ui.base import android.os.Bundle -import android.util.Log import android.view.View import android.widget.FrameLayout import androidx.annotation.DrawableRes @@ -20,7 +19,6 @@ import com.tonapps.uikit.icon.UIKitIcon import uikit.R import uikit.extensions.applyNavBottomPadding import uikit.extensions.collectFlow -import uikit.extensions.getDimensionPixelSize import uikit.extensions.topScrolled import uikit.widget.HeaderView import uikit.widget.SimpleRecyclerView diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletActivity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletActivity.kt index 03f99969c..4b8736335 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletActivity.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletActivity.kt @@ -3,8 +3,8 @@ package com.tonapps.tonkeeper.ui.base import android.content.Context import android.os.Bundle import android.widget.FrameLayout -import com.tonapps.tonkeeperx.R import com.tonapps.extensions.activity +import com.tonapps.tonkeeperx.R import uikit.navigation.NavigationActivity abstract class BaseWalletActivity: NavigationActivity(), BaseWalletVM.Holder { @@ -25,7 +25,6 @@ abstract class BaseWalletActivity: NavigationActivity(), BaseWalletVM.Holder { } companion object { - fun findBaseView(context: Context): FrameLayout? { val activity = (context.activity as? BaseWalletActivity) ?: return null return activity.findViewById(R.id.base) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletScreen.kt index 8a6fc05c2..b10823aad 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletScreen.kt @@ -7,15 +7,14 @@ import android.view.View import android.view.ViewGroup import androidx.annotation.LayoutRes import androidx.core.view.WindowInsetsControllerCompat -import com.tonapps.tonkeeper.RemoteConfig -import com.tonapps.tonkeeper.core.AnalyticsHelper +import com.tonapps.core.flags.RemoteConfig +import com.tonapps.bus.core.AnalyticsHelper import com.tonapps.tonkeeper.koin.analytics import com.tonapps.tonkeeper.koin.remoteConfig import com.tonapps.tonkeeper.koin.serverConfig import com.tonapps.tonkeeper.ui.screen.root.RootActivity import com.tonapps.wallet.api.entity.ConfigEntity import uikit.base.BaseFragment -import uikit.extensions.gestureNavigationEnabled import uikit.navigation.Navigation abstract class BaseWalletScreen( @@ -42,7 +41,8 @@ abstract class BaseWalletScreen( val rootActivity: RootActivity? get() = activity as? RootActivity - abstract val viewModel: BaseWalletVM + @Deprecated("Use AsyncViewModel") + abstract val viewModel: BaseWalletVM? private val isBottomSheet: Boolean get() = this is BottomSheet @@ -75,7 +75,7 @@ abstract class BaseWalletScreen( if (isBottomSheet && isAppearanceLightStatusBars) { rootActivity?.setAppearanceLight(false) } - viewModel.attachHolder(this) + viewModel?.attachHolder(this) return super.onCreateView(inflater, container, savedInstanceState) } @@ -84,6 +84,6 @@ abstract class BaseWalletScreen( if (isBottomSheet && isAppearanceLightStatusBars) { rootActivity?.setAppearanceLight(true) } - viewModel.detachHolder() + viewModel?.detachHolder() } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletVM.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletVM.kt index 1a2711e7b..78e8427ea 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletVM.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/BaseWalletVM.kt @@ -2,37 +2,26 @@ package com.tonapps.tonkeeper.ui.base import android.app.Application import android.content.Context -import android.os.Handler -import android.util.Log import androidx.annotation.StringRes import androidx.annotation.UiThread -import androidx.core.content.ContextCompat -import androidx.fragment.app.FragmentActivity import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.tonapps.extensions.bestMessage -import com.tonapps.extensions.isUIThread +import com.tonapps.async.Async +import com.tonapps.log.L import com.tonapps.tonkeeper.extensions.loading import com.tonapps.tonkeeper.extensions.showToast +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.observeOn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import uikit.base.BaseFragment import uikit.navigation.Navigation -import uikit.navigation.Navigation.Companion.navigation import java.lang.ref.WeakReference -import java.util.concurrent.Executor abstract class BaseWalletVM( app: Application @@ -53,9 +42,23 @@ abstract class BaseWalletVM( val context: Context get() = holder?.uiContext ?: getApplication() - private val navigation: Navigation? + protected val navigation: Navigation? get() = Navigation.from(context) + @Suppress("MemberVisibilityCanBePrivate") + protected val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + L.e(throwable, "Coroutine Exception Handler") + } + + private val job = SupervisorJob() + + @Suppress("MemberVisibilityCanBePrivate") + protected val commonScope = job + exceptionHandler + + // Bg + protected val bgDispatcher = Async.Io + protected val bgScope = CoroutineScope(commonScope + bgDispatcher) + open fun attachHolder(holder: Holder) { holderRef = WeakReference(holder) } @@ -76,6 +79,7 @@ abstract class BaseWalletVM( override fun onCleared() { super.onCleared() detachHolder() + job.cancel() } @UiThread diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/InjectedTonConnectScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/InjectedTonConnectScreen.kt index 6daa80aba..e7befc763 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/InjectedTonConnectScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/InjectedTonConnectScreen.kt @@ -6,17 +6,24 @@ import android.webkit.WebResourceRequest import androidx.annotation.LayoutRes import androidx.core.net.toUri import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.bus.generated.Events +import com.tonapps.core.deeplink.DeepLink +import com.tonapps.core.deeplink.DeepLinkRoute +import com.tonapps.core.helper.WalletRedMetadata import com.tonapps.extensions.appVersionName import com.tonapps.extensions.bestMessage +import com.tonapps.extensions.currentTimeMillis +import com.tonapps.extensions.currentTimeSecondsInt import com.tonapps.extensions.filterList -import com.tonapps.tonkeeper.deeplink.DeepLink -import com.tonapps.tonkeeper.deeplink.DeepLinkRoute +import com.tonapps.extensions.generateUuid import com.tonapps.tonkeeper.extensions.normalizeTONSites import com.tonapps.tonkeeper.extensions.toast import com.tonapps.tonkeeper.helper.BrowserHelper import com.tonapps.tonkeeper.manager.tonconnect.ConnectRequest import com.tonapps.tonkeeper.manager.tonconnect.TonConnect -import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge import com.tonapps.tonkeeper.manager.tonconnect.bridge.BridgeException import com.tonapps.tonkeeper.manager.tonconnect.bridge.JsonBuilder import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.BridgeError @@ -29,13 +36,11 @@ import com.tonapps.tonkeeper.ui.screen.send.transaction.SendTransactionScreen import com.tonapps.tonkeeper.ui.screen.sign.SignDataScreen import com.tonapps.tonkeeper.ui.screen.watchonly.WatchInfoScreen import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity import com.tonapps.wallet.data.core.entity.SignRequestEntity import com.tonapps.wallet.data.dapps.entities.AppConnectEntity import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map -import org.json.JSONArray import org.json.JSONObject import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.activityViewModel @@ -44,7 +49,7 @@ import java.util.concurrent.CancellationException abstract class InjectedTonConnectScreen(@LayoutRes layoutId: Int, wallet: WalletEntity): WalletContextScreen(layoutId, wallet) { - private val tonConnectManager: TonConnectManager by inject() + private val tonConnectBridge: ITonConnectBridge by inject() private val api: API by inject() private val rootViewModel: RootViewModel by activityViewModel() @@ -111,7 +116,7 @@ abstract class InjectedTonConnectScreen(@LayoutRes layoutId: Int, wallet: Wallet suspend fun tonapiFetch( url: String, options: String - ) = api.tonapiFetch(url, options) + ) = api.tonapiFetch(url, options, wallet.network) suspend fun tonconnect( version: Int, @@ -122,11 +127,11 @@ abstract class InjectedTonConnectScreen(@LayoutRes layoutId: Int, wallet: Wallet return JsonBuilder.connectEventError(BridgeError.badRequest("Version $version is not supported")) } val activity = requireContext().activity ?: return JsonBuilder.connectEventError(BridgeError.unknown("internal client error")) - if (tonConnectManager.isScam(requireContext(), wallet, request.manifestUrl.toUri(), webView.url!!.toUri(), startUri)) { + if (tonConnectBridge.isScam(requireContext(), wallet, request.manifestUrl.toUri(), webView.url!!.toUri(), startUri)) { return JsonBuilder.connectEventError(BridgeError.unknown("internal client error")) } - return tonConnectManager.launchConnectFlow( + return tonConnectBridge.launchConnectFlow( activity = activity, tonConnect = TonConnect.fromJsInject(request, webView.url?.toUri()), wallet = wallet, @@ -141,50 +146,111 @@ abstract class InjectedTonConnectScreen(@LayoutRes layoutId: Int, wallet: Wallet val proof = SignDataScreen.run(requireContext(), wallet, uri, payload) return JsonBuilder.responseSignData(message.id, proof, wallet.address, payload) } catch (e: CancellationException) { - context?.let { tonConnectManager.showLogoutAppBar(wallet, it, uri) } + context?.let { tonConnectBridge.showLogoutAppBar(wallet, it, uri) } return JsonBuilder.responseError(message.id, BridgeError.userDeclinedTransaction()) } catch (e: Throwable) { return JsonBuilder.responseError(message.id, BridgeError.unknown(e.bestMessage)) } } - suspend fun tonconnectSend(array: JSONArray, showLogout: Boolean = true): JSONObject { + suspend fun tonconnectSend(tx: JSONObject, showLogout: Boolean = true): JSONObject { var id = 0L try { - val messages = BridgeEvent.Message.parse(array) - if (messages.size == 1) { - val message = messages.first() - id = message.id - if (wallet.isWatchOnly) { - navigation?.add(WatchInfoScreen.newInstance(wallet)) - return JsonBuilder.responseError(id, BridgeError.userDeclinedTransaction()) - } - if (message.method == BridgeMethod.SIGN_DATA) { - return tonconnectSignData(message) - } else if (message.method != BridgeMethod.SEND_TRANSACTION) { - return JsonBuilder.responseError(id, BridgeError.methodNotSupported("Method \"${message.method}\" not supported.")) - } - val signRequests = message.params.map { SignRequestEntity(it, uri) } - if (signRequests.size != 1) { - return JsonBuilder.responseError(id, BridgeError.badRequest("Request contains excess transactions. Required: 1, Provided: ${signRequests.size}")) - } - val signRequest = signRequests.first() - return try { - val boc = SendTransactionScreen.run(requireContext(), wallet, signRequest) - JsonBuilder.responseSendTransaction(id, boc) - } catch (e: CancellationException) { - if (showLogout) { - context?.let { tonConnectManager.showLogoutAppBar(wallet, it, uri) } - } - JsonBuilder.responseError(id, BridgeError.userDeclinedTransaction()) - } catch (e: BridgeException) { - JsonBuilder.responseError(id, BridgeError.badRequest(e.bestMessage)) - } catch (e: Throwable) { - FirebaseCrashlytics.getInstance().recordException(e) - JsonBuilder.responseError(id, BridgeError.unknown(e.bestMessage)) + val message = BridgeEvent.Message(tx) + id = message.id + if (wallet.isWatchOnly) { + navigation?.add(WatchInfoScreen.newInstance(wallet)) + return JsonBuilder.responseError(id, BridgeError.userDeclinedTransaction()) + } + if (message.method == BridgeMethod.SIGN_DATA) { + return tonconnectSignData(message) + } else if (message.method != BridgeMethod.SEND_TRANSACTION) { + return JsonBuilder.responseError(id, BridgeError.methodNotSupported("Method \"${message.method}\" not supported.")) + } + val signRequests = message.params.map { SignRequestEntity(it, uri) } + if (signRequests.size != 1) { + return JsonBuilder.responseError(id, BridgeError.badRequest("Request contains excess transactions. Required: 1, Provided: ${signRequests.size}")) + } + val signRequest = signRequests.first() + val operationId = generateUuid() + val startedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opAttempt( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.TonConnect, + operation = Events.RedOperations.RedOperationsOperation.ConfirmTransaction, + attemptSource = null, + startedAtMs = currentTimeSecondsInt(), + otherMetadata = WalletRedMetadata.walletKit(), + ) + return try { + val boc = SendTransactionScreen.run(requireContext(), wallet, signRequest) + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.TonConnect, + operation = Events.RedOperations.RedOperationsOperation.ConfirmTransaction, + outcome = Events.RedOperations.RedOperationsOutcome.Success, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + errorCode = null, + errorMessage = null, + errorType = null, + stage = null, + otherMetadata = WalletRedMetadata.walletKit(), + ) + JsonBuilder.responseSendTransaction(id, boc) + } catch (e: CancellationException) { + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.TonConnect, + operation = Events.RedOperations.RedOperationsOperation.ConfirmTransaction, + outcome = Events.RedOperations.RedOperationsOutcome.Cancel, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + errorCode = null, + errorMessage = null, + errorType = null, + stage = null, + otherMetadata = WalletRedMetadata.walletKit(), + ) + if (showLogout) { + context?.let { tonConnectBridge.showLogoutAppBar(wallet, it, uri) } } - } else { - return JsonBuilder.responseError(id, BridgeError.badRequest("Request contains excess messages. Required: 1, Provided: ${messages.size}")) + JsonBuilder.responseError(id, BridgeError.userDeclinedTransaction()) + } catch (e: BridgeException) { + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.TonConnect, + operation = Events.RedOperations.RedOperationsOperation.ConfirmTransaction, + outcome = Events.RedOperations.RedOperationsOutcome.Fail, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + errorCode = null, + errorMessage = e.bestMessage, + errorType = e.javaClass.simpleName, + stage = null, + otherMetadata = WalletRedMetadata.walletKit(), + ) + JsonBuilder.responseError(id, BridgeError.badRequest(e.bestMessage)) + } catch (e: Throwable) { + val finishedAtMs = currentTimeMillis() + AnalyticsHelper.Default.events.redOperations.opTerminal( + operationId = operationId, + flow = Events.RedOperations.RedOperationsFlow.TonConnect, + operation = Events.RedOperations.RedOperationsOperation.ConfirmTransaction, + outcome = Events.RedOperations.RedOperationsOutcome.Fail, + durationMs = (finishedAtMs - startedAtMs).toDouble(), + finishedAtMs = currentTimeSecondsInt(), + errorCode = null, + errorMessage = e.bestMessage, + errorType = e.javaClass.simpleName, + stage = null, + otherMetadata = WalletRedMetadata.walletKit(), + ) + FirebaseCrashlytics.getInstance().recordException(e) + JsonBuilder.responseError(id, BridgeError.unknown(e.bestMessage)) } } catch (e: Throwable) { navigation?.toast(e.bestMessage) @@ -196,19 +262,19 @@ abstract class InjectedTonConnectScreen(@LayoutRes layoutId: Int, wallet: Wallet abstract class ViewModel( app: Application, private val wallet: WalletEntity, - private val tonConnectManager: TonConnectManager + private val tonConnectBridge: ITonConnectBridge ): BaseWalletVM(app) { abstract val url: Uri - val connectionFlow = tonConnectManager.walletConnectionsFlow(wallet).filterList { connection -> + val connectionFlow = tonConnectBridge.walletConnectionsFlow(wallet).filterList { connection -> connection.type == AppConnectEntity.Type.Internal && connection.appUrl.host == url.host }.map { it.firstOrNull() } fun disconnect() { - tonConnectManager.disconnect(wallet, url, AppConnectEntity.Type.Internal) + tonConnectBridge.disconnect(wallet, url, AppConnectEntity.Type.Internal) } suspend fun restoreConnection(currentUri: Uri?): JSONObject { @@ -222,12 +288,12 @@ abstract class InjectedTonConnectScreen(@LayoutRes layoutId: Int, wallet: Wallet private suspend fun loadConnection(attempt: Int = 0, currentUri: Uri?): AppConnectEntity? { if (attempt > 3) { - val firstApp = tonConnectManager.getConnection(wallet.accountId, wallet.testnet, url, AppConnectEntity.Type.Internal) + val firstApp = tonConnectBridge.getConnection(wallet.accountId, wallet.network, url, AppConnectEntity.Type.Internal) if (firstApp != null) { return firstApp } if (currentUri != null) { - return tonConnectManager.getConnection(wallet.accountId, wallet.testnet, currentUri, AppConnectEntity.Type.Internal) + return tonConnectBridge.getConnection(wallet.accountId, wallet.network, currentUri, AppConnectEntity.Type.Internal) } return null } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/QRCameraScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/QRCameraScreen.kt index 0a933c76b..6032c41a1 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/QRCameraScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/QRCameraScreen.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.base import android.content.Context import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import androidx.annotation.LayoutRes import androidx.annotation.OptIn @@ -26,7 +26,7 @@ import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppArgs import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppScreen import com.tonapps.wallet.api.API import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/ScreenContext.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/ScreenContext.kt index 903659f2b..3dc47a007 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/ScreenContext.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/ScreenContext.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.ui.base import android.os.Parcelable -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import kotlinx.parcelize.Parcelize sealed class ScreenContext: Parcelable { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletContextScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletContextScreen.kt index dbd54b16d..82b1cd734 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletContextScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletContextScreen.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.ui.base import androidx.annotation.LayoutRes -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity abstract class WalletContextScreen( @LayoutRes layoutId: Int, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletFragmentFactory.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletFragmentFactory.kt index 734d22629..d8c093ad4 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletFragmentFactory.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/WalletFragmentFactory.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.ui.base import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import uikit.navigation.Navigation.Companion.navigation class WalletFragmentFactory: FragmentFactory() { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeScreen.kt index 154242e4d..c257f3865 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeScreen.kt @@ -1,9 +1,7 @@ package com.tonapps.tonkeeper.ui.base.compose import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.ui.platform.ComposeView @@ -13,7 +11,7 @@ import com.tonapps.tonkeeper.ui.base.BaseWalletScreen import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeperx.R import org.koin.android.ext.android.inject -import ui.theme.UIKit +import ui.theme.MoonTheme abstract class ComposeScreen(screenContext: C) : BaseWalletScreen(R.layout.fragment_compose_host, screenContext) { @@ -21,11 +19,11 @@ abstract class ComposeScreen(screenContext: C) : BaseWalletScr override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val theme = environment.theme view.findViewById(R.id.compose_view).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - UIKit(colorScheme = theme) { + val theme = environment.theme + MoonTheme(colorScheme = theme) { ScreenContent() } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeWalletScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeWalletScreen.kt index 658b50852..7c635946c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeWalletScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/compose/ComposeWalletScreen.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.ui.base.compose import com.tonapps.tonkeeper.ui.base.ScreenContext -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity abstract class ComposeWalletScreen(wallet: WalletEntity): ComposeScreen(ScreenContext.Wallet(wallet)) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerScreen.kt index a9aca6427..b6a9e74fc 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerScreen.kt @@ -5,7 +5,7 @@ import android.view.View import com.tonapps.tonkeeper.os.AndroidCurrency import com.tonapps.tonkeeper.ui.base.picker.BasePickerScreen import com.tonapps.tonkeeper.ui.base.picker.currency.list.Adapter -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import uikit.extensions.collectFlow diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerViewModel.kt index 8400e47ce..64211a6d0 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/CurrencyPickerViewModel.kt @@ -5,7 +5,7 @@ import com.tonapps.tonkeeper.os.AndroidCurrency import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.base.picker.currency.list.Item import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Holder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Holder.kt index fa4f9ba09..77053636c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Holder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Holder.kt @@ -4,10 +4,12 @@ import android.net.Uri import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.AppCompatTextView +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.tonkeeper.extensions.spannableCode import com.tonapps.tonkeeper.ui.component.CountryFlagView import com.tonapps.tonkeeperx.R import com.tonapps.uikit.list.BaseListHolder +import com.tonapps.wallet.localization.Localization import uikit.extensions.drawable import uikit.widget.AsyncImageView @@ -26,7 +28,11 @@ class Holder( applyDrawableRes(item.drawableRes) applyImageUri(item.iconUri) symbolView.text = item.currency.spannableCode(context) - nameView.text = item.name + nameView.text = if (item.extra.isEmpty() && item.currency.code == WalletCurrency.GRAM_KEY) { + context.getString(Localization.toncoin) + } else { + item.name + } itemView.setOnClickListener { onClick(item) } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Item.kt index 1205d0c31..a77e8eb35 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/base/picker/currency/list/Item.kt @@ -5,7 +5,7 @@ import com.tonapps.extensions.toUriOrNull import com.tonapps.tonkeeper.os.AndroidCurrency import com.tonapps.uikit.list.BaseListItem import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency data class Item( val position: ListCell.Position, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/LottieView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/LottieView.kt index a1421a5bf..0f021880f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/LottieView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/LottieView.kt @@ -2,7 +2,6 @@ package com.tonapps.tonkeeper.ui.component import android.content.Context import android.net.Uri -import android.os.Build import android.util.AttributeSet import android.webkit.WebSettings import android.webkit.WebView @@ -31,9 +30,7 @@ class LottieView @JvmOverloads constructor( setEnableSmoothTransition(false) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, true) - } + setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, true) isVerticalScrollBarEnabled = false isHorizontalScrollBarEnabled = false @@ -81,4 +78,4 @@ class LottieView @JvmOverloads constructor( } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/SnackBarView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/SnackBarView.kt index 1eb5a4625..7fd727550 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/SnackBarView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/SnackBarView.kt @@ -1,18 +1,15 @@ package com.tonapps.tonkeeper.ui.component +import android.app.Activity import android.content.Context import android.util.AttributeSet -import android.util.Log import android.view.ViewGroup -import android.widget.Button import android.widget.FrameLayout import androidx.appcompat.widget.AppCompatTextView import androidx.core.view.doOnLayout -import androidx.core.view.setMargins import androidx.core.view.setPadding import com.tonapps.tonkeeper.ui.base.BaseWalletActivity import com.tonapps.tonkeeperx.R -import uikit.extensions.dp import uikit.extensions.getDimensionPixelSize import uikit.extensions.statusBarHeight import uikit.widget.RowLayout @@ -53,44 +50,67 @@ class SnackBarView @JvmOverloads constructor( } } - fun show() { - translationY = -size.toFloat() + private fun processForTests(durationMs: Long): Long { + val intent = (context as? Activity)?.intent + val isMaestro = intent?.getStringExtra("isMaestro") == "true" + return durationMs + if (isMaestro) 3000L else 0L + } + + fun show(durationMs: Long) { doOnLayout { - startShowAnimation() + translationY = hiddenTranslationY() + startShowAnimation(processForTests(durationMs)) } } - private fun startShowAnimation() { + private fun startShowAnimation(durationMs: Long) { animate().translationY((statusBarHeight + offsetMedium).toFloat()).setDuration(300).withEndAction { - hideDelayed() + hideDelayed(durationMs) } } private fun startHideAnimation() { - animate().translationY(-size.toFloat()).setDuration(220).withEndAction { + animate().translationY(hiddenTranslationY()).setDuration(220).withEndAction { hide() } } - private fun hideDelayed() { + private fun hideDelayed(delayMs: Long) { + if (delayMs <= 0) { + return + } postDelayed({ startHideAnimation() - }, 3000) + }, delayMs) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY)) + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + setMeasuredDimension(measuredWidth, maxOf(measuredHeight, size)) + } + + private fun hiddenTranslationY(): Float { + return -maxOf(size, measuredHeight, height).toFloat() } companion object { - fun show(context: Context, text: String, onClickListener: OnClickListener) { + private const val DEFAULT_SHOWING_DURATION_MS = 3000L + + fun show( + context: Context, + text: String, + buttonText: CharSequence, + durationMs: Long = DEFAULT_SHOWING_DURATION_MS, + onClickListener: OnClickListener, + ) { val baseView = BaseWalletActivity.findBaseView(context) ?: return val view = SnackBarView(context) view.setText(text) + view.button.text = buttonText view.setButtonOnClickListener(onClickListener) - view.show() + view.show(durationMs) baseView.addView(view, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply { leftMargin = view.offsetMedium @@ -98,4 +118,4 @@ class SnackBarView @JvmOverloads constructor( }) } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/TonConnectWebView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/TonConnectWebView.kt index b1c3f60ce..43c2da6ce 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/TonConnectWebView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/TonConnectWebView.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.ui.component import android.content.Context import android.net.Uri import android.util.AttributeSet -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.toUriOrNull import uikit.widget.webview.bridge.BridgeWebView diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/UpdateAvailableDialog.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/UpdateAvailableDialog.kt index f973380e0..9a252c7bb 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/UpdateAvailableDialog.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/UpdateAvailableDialog.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.component import android.content.Context import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import com.tonapps.tonkeeper.manager.apk.APKManager import com.tonapps.tonkeeperx.R diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/WordEditText.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/WordEditText.kt index 98f1c3731..ab1bd4088 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/WordEditText.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/WordEditText.kt @@ -6,7 +6,7 @@ import android.graphics.Paint import android.graphics.Rect import android.text.Editable import android.util.AttributeSet -import android.util.Log +import com.tonapps.log.L import android.util.TypedValue import android.view.KeyEvent import android.view.View diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/ChartDrawable.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/ChartDrawable.kt deleted file mode 100644 index 846d3d6a1..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/ChartDrawable.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.tonapps.tonkeeper.ui.component.chart - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.LinearGradient -import android.graphics.Paint -import android.graphics.Path -import android.graphics.PointF -import android.graphics.Rect -import android.graphics.Shader -import android.util.Log -import androidx.core.graphics.withTranslation -import com.tonapps.wallet.api.entity.ChartEntity -import uikit.extensions.withAlpha -import kotlin.math.max - -class ChartDrawable(context: Context): BaseChartDrawable(context) { - - private var data = listOf() - private val path = Path() - private var isSquare = false - - val entities: List - get() = data.toList() - - val stepX: Float - get() = chartWidth / data.size - - val isEmpty: Boolean - get() = data.isEmpty() - - private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = accentColor - strokeWidth = strokeSize - style = Paint.Style.STROKE - } - - private val gradientPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = accentColor - style = Paint.Style.FILL - } - - fun setData(data: List, isSquare: Boolean) { - this.data = data - this.isSquare = isSquare - - path.reset() - } - - fun getDotY(entity: ChartEntity): Float { - val y = chartHeight - ((entity.price - data.minOf { it.price }) / (data.maxOf { it.price } - data.minOf { it.price })) * chartHeight - if (y.isNaN() || y.isInfinite()) { - return chartHeight / 2f - } - return y - } - - override fun draw(canvas: Canvas) { - if (data.isEmpty()) { - return - } - - if (path.isEmpty) { - buildPath() - } - - canvas.withTranslation(bounds.left.toFloat(), bounds.top + strokeSize) { - drawPath(path, linePaint) - drawPath(path, gradientPaint) - } - } - - override fun onBoundsChange(bounds: Rect) { - super.onBoundsChange(bounds) - gradientPaint.shader = LinearGradient( - 0f, 0f, 0f, chartHeight, - accentColor.withAlpha(76), - Color.TRANSPARENT, - Shader.TileMode.CLAMP - ) - } - - private fun buildPath() { - var maxPrice = data.maxOf { it.price } - var minPrice = data.minOf { it.price } - - if (maxPrice == minPrice) { - maxPrice += 1f - minPrice = 0f - } - - val priceRange = max(maxPrice - minPrice, 0.000000000f) - - val points = mutableListOf() - for ((index, entity) in data.withIndex()) { - val x = index * stepX - val y = chartHeight - ((entity.price - minPrice) / priceRange) * chartHeight - points.add(PointF(x, y)) - } - - points.first().let { point -> - path.moveTo(point.x - strokeSize, point.y) - path.lineTo(point.x, point.y) - } - - buildPath(points) - - /*if (isSquare) { - buildSquarePath(points) - } else { - buildPath(points) - }*/ - - buildPath(points) - - path.close() - } - - private fun buildPath(points: List) { - for (point in points) { - path.lineTo(point.x, point.y) - } - points.last().let { - path.lineTo(it.x + strokeSize, it.y + (strokeSize * 2)) - path.lineTo(chartWidth, chartHeight + (strokeSize * 2)) - path.lineTo(-strokeSize, chartHeight + (strokeSize * 2)) - } - } - - private fun buildSquarePath(points: List) { - for (i in points.indices) { - val point = points[i] - if (i < points.size - 1) { - val nextPoint = points[i + 1] - path.lineTo(nextPoint.x, point.y) - path.lineTo(nextPoint.x, nextPoint.y) - } else { - path.lineTo(point.x, point.y) - } - } - points.last().let { - path.lineTo(it.x + strokeSize, it.y) - path.lineTo(it.x + strokeSize, chartHeight + strokeSize) - path.lineTo(-strokeSize, chartHeight + strokeSize) - } - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/ChartView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/ChartView.kt deleted file mode 100644 index 4ef164069..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/ChartView.kt +++ /dev/null @@ -1,129 +0,0 @@ -package com.tonapps.tonkeeper.ui.component.chart - -import android.content.Context -import android.graphics.Canvas -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.util.Log -import android.view.MotionEvent -import android.view.View -import com.tonapps.wallet.api.entity.ChartEntity - -class ChartView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0, -) : View(context, attrs, defStyle) { - - private val chartDrawable = ChartDrawable(context) - private val touchIndicatorDrawable = TouchIndicatorDrawable(context) - private val loadingDrawable = LoadingDrawable(context) - - private var selectedEntity: ChartEntity? = null - var onEntitySelected: ((ChartEntity?) -> Unit)? = null - - init { - background = chartDrawable - touchIndicatorDrawable.callback = this - loadingDrawable.callback = this - } - - fun setData(data: List, isSquare: Boolean) { - if (data.isEmpty()) { - chartDrawable.setData(emptyList(), isSquare) - } else if (data.size > 100) { - chartDrawable.setData(data, isSquare) - } else { - val newData = mutableListOf() - val countCopy = 4 - for (entity in data) { - for (i in 0 until countCopy) { - newData.add(entity) - } - } - chartDrawable.setData(newData, isSquare) - } - - if (width > 0 && height > 0) { - checkLoadingAnimation() - } - } - - private fun checkLoadingAnimation() { - if (chartDrawable.isEmpty) { - loadingDrawable.startAnimation() - } else { - loadingDrawable.stopAnimation() - } - } - - override fun verifyDrawable(who: Drawable): Boolean { - return super.verifyDrawable(who) || who == touchIndicatorDrawable || who == loadingDrawable - } - - override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { - super.onSizeChanged(w, h, oldw, oldh) - touchIndicatorDrawable.setBounds(0, 0, w, h) - loadingDrawable.setBounds(0, 0, w, h) - checkLoadingAnimation() - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - if (chartDrawable.isEmpty) { - loadingDrawable.draw(canvas) - } else { - touchIndicatorDrawable.draw(canvas) - } - } - - override fun onTouchEvent(event: MotionEvent): Boolean { - when (event.action) { - MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - parent?.requestDisallowInterceptTouchEvent(true) - setTouch(event.x - paddingLeft) - return true - } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - resetTouch() - parent?.requestDisallowInterceptTouchEvent(false) - return true - } - } - return super.onTouchEvent(event) - } - - private fun setTouch(x: Float) { - selectedEntity = null - onEntitySelected?.invoke(null) - updateSelectedEntity(x) - - selectedEntity?.let { - touchIndicatorDrawable.setData(x, chartDrawable.getDotY(it)) - } - } - - private fun resetTouch() { - selectedEntity = null - onEntitySelected?.invoke(null) - touchIndicatorDrawable.setData(-1f, -1f) - } - - private fun updateSelectedEntity(x: Float) { - val entities = chartDrawable.entities - if (entities.isEmpty()) { - return - } - val index = (x / chartDrawable.stepX).toInt().coerceIn(0, entities.size - 1) - val selected = entities[index] - if (selected != selectedEntity) { - selectedEntity = selected - onEntitySelected?.invoke(selectedEntity) - } - } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - loadingDrawable.stopAnimation() - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/LoadingDrawable.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/LoadingDrawable.kt deleted file mode 100644 index 0f5d33a0e..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/chart/LoadingDrawable.kt +++ /dev/null @@ -1,96 +0,0 @@ -package com.tonapps.tonkeeper.ui.component.chart - -import android.animation.ValueAnimator -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Path -import android.graphics.PointF -import android.view.animation.LinearInterpolator -import androidx.core.animation.doOnRepeat -import uikit.extensions.dp -import kotlin.math.sin - -class LoadingDrawable(context: Context) : BaseChartDrawable(context) { - private var path = Path() - private val snakePoints = mutableListOf() - private val animationDuration = 3200L - private var progress = 0f - - private val animator = ValueAnimator.ofFloat(0f, 1f).apply { - duration = animationDuration - repeatCount = ValueAnimator.INFINITE - interpolator = LinearInterpolator() - addUpdateListener { animator -> - progress = animator.animatedFraction - if (progress <= 0.01f) { - snakePoints.clear() - path.reset() - } - updateSnake() - invalidateSelf() - } - doOnRepeat { - snakePoints.clear() - path.reset() - } - } - - private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = accentColor - strokeWidth = strokeSize - style = Paint.Style.STROKE - strokeCap = Paint.Cap.ROUND - strokeJoin = Paint.Join.ROUND - } - - override fun draw(canvas: Canvas) { - canvas.drawPath(path, linePaint) - } - - fun stopAnimation() { - animator.cancel() - resetSnake() - } - - fun startAnimation() { - if (!animator.isRunning) { - resetSnake() - animator.start() - } - } - - private fun resetSnake() { - progress = 0f - snakePoints.clear() - path.reset() - } - - private fun updateSnake() { - val currentX = progress * chartWidth - val newY = calculateNewY(progress) - snakePoints.add(PointF(currentX, newY)) - - path.reset() - if (snakePoints.isNotEmpty()) { - path.moveTo(snakePoints.first().x, snakePoints.first().y) - for (i in 1 until snakePoints.size) { - path.lineTo(snakePoints[i].x, snakePoints[i].y) - } - } - } - - private fun calculateNewY(x: Float): Float { - val baseY = chartHeight / 2 - val amplitude = chartHeight * 0.25f // Adjust this to change the vertical range - val frequency = 3f // Adjust this to change how often the line moves up and down - - // Use multiple sine waves with different frequencies to create a more natural movement - val y = baseY + - amplitude * 0.6f * sin(x * frequency * 2 * Math.PI.toFloat()) + - amplitude * 0.3f * sin(x * frequency * 4 * Math.PI.toFloat() + 0.5f) + - amplitude * 0.1f * sin(x * frequency * 8 * Math.PI.toFloat() + 1.0f) - - return y.coerceIn(strokeSize, chartHeight - strokeSize) - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/CoinInputView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/CoinInputView.kt index 10c82bbbc..8d4d5878a 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/CoinInputView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/CoinInputView.kt @@ -4,7 +4,7 @@ import android.content.Context import android.graphics.Paint import android.text.TextPaint import android.util.AttributeSet -import android.util.Log +import com.tonapps.log.L import android.view.View import androidx.core.content.res.ResourcesCompat import androidx.core.widget.doAfterTextChanged @@ -18,8 +18,8 @@ import com.tonapps.tonkeeper.ui.component.token.CurrencyPickerView import com.tonapps.tonkeeper.ui.component.token.TokenPickerView import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.textSecondaryColor -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization import uikit.extensions.dp import uikit.extensions.focusWithKeyboard diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/format/CoinFormattingTextWatcher.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/format/CoinFormattingTextWatcher.kt index 8169b3afb..9123c2405 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/format/CoinFormattingTextWatcher.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/coin/format/CoinFormattingTextWatcher.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.component.coin.format import android.text.Editable import android.text.TextWatcher -import android.util.Log +import com.tonapps.log.L import com.tonapps.icu.CurrencyFormatter import uikit.extensions.deleteLast import uikit.extensions.replaceAll diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/ColorAdapter.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/ColorAdapter.kt index e36bc93c6..800409dd5 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/ColorAdapter.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/ColorAdapter.kt @@ -4,7 +4,7 @@ import android.graphics.Color import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.tonapps.tonkeeperx.R -import com.tonapps.wallet.data.account.WalletColor +import com.tonapps.blockchain.model.legacy.WalletColor import uikit.extensions.inflate import uikit.widget.ColorView diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/LabelEditorView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/LabelEditorView.kt index 18c66b618..ed51c5a91 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/LabelEditorView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/label/LabelEditorView.kt @@ -4,7 +4,7 @@ import android.content.Context import android.graphics.Color import android.graphics.Rect import android.util.AttributeSet -import android.util.Log +import com.tonapps.log.L import android.view.View import android.view.ViewGroup import android.view.WindowInsets @@ -23,7 +23,7 @@ import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.backgroundPageColor import com.tonapps.uikit.color.stateList import com.tonapps.uikit.list.LinearLayoutManager -import com.tonapps.wallet.data.account.WalletColor +import com.tonapps.blockchain.model.legacy.WalletColor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/CurrencyPickerView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/CurrencyPickerView.kt index a11c2ae40..7cc32481c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/CurrencyPickerView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/CurrencyPickerView.kt @@ -10,8 +10,8 @@ import androidx.appcompat.widget.AppCompatTextView import androidx.core.view.setPadding import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.textSecondaryColor -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletCurrency import uikit.extensions.dp import uikit.widget.AsyncImageView import uikit.widget.RowLayout @@ -35,7 +35,7 @@ open class CurrencyPickerView @JvmOverloads constructor( id = token.address, icon = token.imageUri, title = token.symbol, - subtitle = if (token.isTrc20) { + subtitle = if (token.isUsdtTrc20) { "TRC20" } else if (token.isUsdt) { "TON" @@ -47,7 +47,7 @@ open class CurrencyPickerView @JvmOverloads constructor( ) constructor(currency: WalletCurrency): this( - id = currency.chain.name, + id = currency.chain.symbol, icon = currency.iconUri ?: Uri.EMPTY, title = currency.code, subtitle = currency.chainName, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/TokenPickerView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/TokenPickerView.kt index 204c61b66..06a0af0d3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/TokenPickerView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/token/TokenPickerView.kt @@ -3,11 +3,11 @@ package com.tonapps.tonkeeper.ui.component.token import android.content.Context import android.os.Bundle import android.util.AttributeSet -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.getParcelableCompat import com.tonapps.tonkeeper.ui.screen.token.picker.TokenPickerScreen -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import uikit.extensions.hideKeyboard import uikit.navigation.Navigation diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/wallet/WalletHeaderView.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/wallet/WalletHeaderView.kt index 684812b06..aa0ef0904 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/wallet/WalletHeaderView.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/component/wallet/WalletHeaderView.kt @@ -4,30 +4,33 @@ import android.content.Context import android.content.res.ColorStateList import android.graphics.Color import android.util.AttributeSet -import android.util.Log import android.view.GestureDetector import android.view.MotionEvent import android.view.View import android.view.WindowInsets import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatTextView -import androidx.core.view.GestureDetectorCompat import androidx.core.view.WindowInsetsCompat +import com.tonapps.core.flags.TooltipManager +import com.tonapps.core.flags.TooltipState +import com.tonapps.core.flags.WalletTooltip import com.tonapps.emoji.ui.EmojiView import com.tonapps.tonkeeper.extensions.fixW5Title import com.tonapps.tonkeeper.extensions.isLightTheme import com.tonapps.tonkeeperx.R -import com.tonapps.uikit.color.backgroundContentTintColor import com.tonapps.uikit.color.backgroundPageColor import com.tonapps.uikit.color.backgroundTransparentColor -import com.tonapps.wallet.data.account.Wallet +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.wallet.localization.Localization import uikit.drawable.BarDrawable import uikit.drawable.DotDrawable import uikit.drawable.HeaderDrawable +import uikit.extensions.dp import uikit.extensions.getDimensionPixelSize -import uikit.extensions.setPaddingHorizontal import uikit.extensions.setPaddingTop import uikit.extensions.statusBarHeight +import uikit.widget.BalloonTooltip import uikit.widget.RowLayout import kotlin.math.abs @@ -62,11 +65,9 @@ class WalletHeaderView @JvmOverloads constructor( val e1 = ev1 ?: return false val diffY = e2.y - e1.y val diffX = e2.x - e1.x - if (abs(diffX) > abs(diffY)) { - if (abs(diffX) > threshold && abs(velocityX) > velocityThreshold) { - doWalletSwipe?.invoke(diffX > 0) - return true - } + if (abs(diffX) > abs(diffY) && abs(diffX) > threshold && abs(velocityX) > velocityThreshold) { + doWalletSwipe?.invoke(diffX > 0) + return true } return false @@ -74,7 +75,8 @@ class WalletHeaderView @JvmOverloads constructor( } private val swipeDetector = GestureDetector(context, swipeGestureListener, handler) - private val supportView: View + private val scanView: AppCompatImageView + private val historyView: AppCompatImageView private val settingsView: View private val walletView: View private val emojiView: EmojiView @@ -89,12 +91,23 @@ class WalletHeaderView @JvmOverloads constructor( } } - var onSupportClick: (() -> Unit)? = null + var onScanClick: (() -> Unit)? = null set(value) { field = value - supportView.setOnClickListener { value?.invoke() } + scanView.setOnClickListener { value?.invoke() } } + var onHistoryClick: (() -> Unit)? = null + set(value) { + field = value + historyView.setOnClickListener { + dismissHistoryTooltip() + value?.invoke() + } + } + + private var historyTooltip: BalloonTooltip? = null + var onSettingsClick: (() -> Unit)? = null set(value) { field = value @@ -109,14 +122,17 @@ class WalletHeaderView @JvmOverloads constructor( init { setPadding( - context.getDimensionPixelSize(uikit.R.dimen.offsetMedium), + 0, topOffset, 0, 0 ) super.setBackground(drawable) inflate(context, R.layout.view_wallet_header, this) - supportView = findViewById(R.id.support) + scanView = findViewById(R.id.support) + + scanView.setImageResource(UIKitIcon.ic_qr_viewfinder_thin_28) + historyView = findViewById(R.id.history) settingsView = findViewById(R.id.settings) walletView = findViewById(R.id.wallet) walletView.setOnTouchListener { v, event -> swipeDetector.onTouchEvent(event) } @@ -128,6 +144,61 @@ class WalletHeaderView @JvmOverloads constructor( settingsDot.background = DotDrawable(context) } + fun setHistoryVisible(visible: Boolean) { + historyView.visibility = if (visible) View.VISIBLE else View.GONE + if (visible) { + tryShowHistoryTooltip() + } + } + + private fun tryShowHistoryTooltip() { + if (historyTooltip != null) { + return + } + if (!WalletTooltip.HistoryHere.shouldShow) { + return + } + + historyView.post { + if (!historyView.isAttachedToWindow || historyView.visibility != View.VISIBLE) { + return@post + } + historyTooltip = BalloonTooltip.show( + anchorView = historyView, + badgeText = context.getString(Localization.tooltip_new), + messageText = context.getString(Localization.tooltip_history_here_now), + placement = BalloonTooltip.Placement.BOTTOM, + offset = 12.dp, + autoDismissMs = BalloonTooltip.NO_AUTO_DISMISS, + onShown = { + TooltipManager.markShownInSession(WalletTooltip.HistoryHere.key) + TooltipManager.incrementShowCount(WalletTooltip.HistoryHere.key) + markHistoryTooltipShown() + }, + onClickListener = { + markHistoryTooltipShown() + dismissHistoryTooltip() + }, + ) + } + } + + private fun markHistoryTooltipShown() { + if (WalletTooltip.HistoryHere.state != TooltipState.ALWAYS) { + TooltipManager.setState(WalletTooltip.HistoryHere.key, TooltipState.SHOWN) + } + } + + private fun dismissHistoryTooltip() { + historyTooltip?.dismiss() + historyTooltip = null + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + dismissHistoryTooltip() + } + fun setDot(dot: Boolean) { settingsDot.visibility = if (dot) View.VISIBLE else View.GONE } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletScreen.kt index e793dcd8d..6afc9da3a 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletScreen.kt @@ -40,6 +40,7 @@ class AddWalletScreen: BaseListWalletScreen(ScreenContext.No Item.SIGNER_WALLET_ID -> openScreen(SignerAddScreen.newInstance()) Item.LEDGER_WALLET_ID -> openScreen(PairLedgerScreen.newInstance()) Item.KEYSTONE_WALLET_ID -> openScreen(KeystoneAddScreen.newInstance()) + Item.TETRA_WALLET_ID -> openScreen(InitScreen.newInstance(InitArgs.Type.Tetra)) } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletViewModel.kt index 3c9025ed2..9be8de5b6 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/AddWalletViewModel.kt @@ -1,6 +1,8 @@ package com.tonapps.tonkeeper.ui.screen.add import android.app.Application +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.tonkeeper.core.DevSettings import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.add.list.Item import com.tonapps.wallet.api.API @@ -22,13 +24,19 @@ class AddWalletViewModel( uiItems.add(Item.header(Localization.import_wallet, Localization.import_wallet_subtitle)) } uiItems.add(Item.import) - uiItems.add(Item.watch) - uiItems.add(Item.testnet) - if (!api.config.flags.disableSigner) { + if (!api.getConfig(TonNetwork.MAINNET).flags.disableSigner) { uiItems.add(Item.signer) } uiItems.add(Item.keystone) uiItems.add(Item.ledger) + uiItems.add(Item.otherOptionsTitle) + uiItems.add(Item.watch) + uiItems.add(Item.forDevelopersTitle) + uiItems.add(Item.testnet) + if (DevSettings.tetraEnabled) { + uiItems.add(Item.tetra) + } + uiItems.toList() } } \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Adapter.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Adapter.kt index a073a797f..48645259e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Adapter.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Adapter.kt @@ -3,6 +3,7 @@ package com.tonapps.tonkeeper.ui.screen.add.list import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.tonapps.tonkeeper.ui.screen.add.list.holder.HeaderHolder +import com.tonapps.tonkeeper.ui.screen.add.list.holder.SectionTitleHolder import com.tonapps.tonkeeper.ui.screen.add.list.holder.WalletHolder import com.tonapps.uikit.list.BaseListAdapter import com.tonapps.uikit.list.BaseListHolder @@ -15,6 +16,7 @@ class Adapter( override fun createHolder(parent: ViewGroup, viewType: Int): BaseListHolder { return when(viewType) { Item.TYPE_HEADER -> HeaderHolder(parent) + Item.TYPE_SECTION_TITLE -> SectionTitleHolder(parent) Item.TYPE_WALLET -> WalletHolder(parent, onClick) else -> throw IllegalArgumentException("Unknown viewType: $viewType") } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Item.kt index bb700cf3d..fb63df25d 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/Item.kt @@ -12,6 +12,7 @@ sealed class Item(type: Int): BaseListItem(type) { companion object { const val TYPE_WALLET = -10 const val TYPE_HEADER = -11 + const val TYPE_SECTION_TITLE = -12 const val IMPORT_WALLET_ID = 1 const val WATCH_WALLET_ID = 2 @@ -20,11 +21,16 @@ sealed class Item(type: Int): BaseListItem(type) { const val LEDGER_WALLET_ID = 5 const val KEYSTONE_WALLET_ID = 6 const val NEW_WALLET_ID = 7 + const val TETRA_WALLET_ID = 8 fun header(title: Int, subtitle: Int): Header { return Header(title, subtitle) } + val forDevelopersTitle = SectionTitle(titleResId = Localization.for_developers) + + val otherOptionsTitle = SectionTitle(titleResId = Localization.other_options) + val new = Wallet( id = NEW_WALLET_ID, iconResId = R.drawable.ic_plus_circle_28, @@ -73,6 +79,13 @@ sealed class Item(type: Int): BaseListItem(type) { titleResId = Localization.ledger_title, subtitleResId = Localization.ledger_subtitle ) + + val tetra = Wallet( + id = TETRA_WALLET_ID, + iconResId = R.drawable.ic_tetra_24, + titleResId = Localization.tetra_title, + subtitleResId = Localization.tetra_subtitle + ) } data class Wallet( @@ -87,4 +100,8 @@ sealed class Item(type: Int): BaseListItem(type) { @StringRes val subtitleResId: Int ): Item(TYPE_HEADER) + data class SectionTitle( + @StringRes val titleResId: Int, + ): Item(TYPE_SECTION_TITLE) + } \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/holder/SectionTitleHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/holder/SectionTitleHolder.kt new file mode 100644 index 000000000..71fa50c98 --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/add/list/holder/SectionTitleHolder.kt @@ -0,0 +1,21 @@ +package com.tonapps.tonkeeper.ui.screen.add.list.holder + +import android.view.View +import android.view.ViewGroup +import androidx.core.view.marginTop +import com.tonapps.tonkeeper.ui.screen.add.list.Item +import uikit.widget.TextHeaderView + +class SectionTitleHolder(parent: ViewGroup): Holder(TextHeaderView(parent.context)) { + + private val itemActionView = itemView as TextHeaderView + + override fun onBind(item: Item.SectionTitle) { + itemActionView.titleView.visibility = View.GONE + (itemActionView.descriptionView.layoutParams as ViewGroup.MarginLayoutParams) + .apply { topMargin = 0 } + .also { itemActionView.descriptionView.layoutParams = it } + itemActionView.desciption = getString(item.titleResId) + } + +} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckScreen.kt index e2628bd54..f589a726b 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckScreen.kt @@ -1,29 +1,18 @@ package com.tonapps.tonkeeper.ui.screen.backup.check import android.os.Bundle -import android.util.Log import android.view.View import android.widget.Button import androidx.core.view.updatePadding import androidx.core.widget.NestedScrollView -import androidx.lifecycle.lifecycleScope import com.tonapps.tonkeeper.koin.walletViewModel -import com.tonapps.tonkeeper.ui.base.BaseWalletScreen -import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeper.ui.base.WalletContextScreen import com.tonapps.tonkeeperx.BuildConfig import com.tonapps.tonkeeperx.R -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.koin.core.parameter.parametersOf import uikit.base.BaseFragment import uikit.extensions.doKeyboardAnimation -import uikit.extensions.pinToBottomInsets -import uikit.extensions.scrollDown import uikit.extensions.scrollView import uikit.widget.HeaderView import uikit.widget.TextHeaderView @@ -93,7 +82,7 @@ class BackupCheckScreen(wallet: WalletEntity): WalletContextScreen(R.layout.frag button.setOnClickListener { saveBackup() } scrollView.doKeyboardAnimation { offset, progress, _ -> - scrollView.updatePadding(bottom = offset) + scrollView.updatePadding(bottom = offset + button.height) button.translationY = -offset.toFloat() if (progress >= .9f || .1f >= progress) { getCurrentFocus()?.let { updateScroll(it) } @@ -149,4 +138,4 @@ class BackupCheckScreen(wallet: WalletEntity): WalletContextScreen(R.layout.frag } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckViewModel.kt index e56b09056..deb0b3211 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/check/BackupCheckViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.backup.BackupRepository import com.tonapps.wallet.data.backup.entities.BackupEntity import kotlinx.coroutines.Dispatchers diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupScreen.kt index fcdf00200..547f1250b 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupScreen.kt @@ -11,7 +11,7 @@ import com.tonapps.tonkeeper.ui.screen.backup.main.list.Adapter import com.tonapps.tonkeeper.ui.screen.backup.main.list.Item import com.tonapps.tonkeeper.ui.screen.phrase.PhraseScreen import com.tonapps.tonkeeperx.BuildConfig -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization import uikit.base.BaseFragment import uikit.extensions.collectFlow @@ -53,7 +53,9 @@ class BackupScreen(wallet: WalletEntity): BaseListWalletScreen + val ctx = context ?: return + + viewModel.getRecoveryPhrase(ctx) { words, error -> if (error != null) { navigation?.toast(error.bestMessage) } else { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupViewModel.kt index 23e09d367..dd4ed30d3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/backup/main/BackupViewModel.kt @@ -2,23 +2,22 @@ package com.tonapps.tonkeeper.ui.screen.backup.main import android.app.Application import android.content.Context -import android.util.Log -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.tonapps.extensions.filterList import com.tonapps.icu.Coins import com.tonapps.icu.Coins.Companion.sumOf import com.tonapps.icu.CurrencyFormatter +import com.tonapps.legacy.enteties.AssetsEntity +import com.tonapps.legacy.enteties.StakedEntity import com.tonapps.tonkeeper.core.BalanceType -import com.tonapps.tonkeeper.core.entities.AssetsEntity -import com.tonapps.tonkeeper.core.entities.StakedEntity +import com.tonapps.tonkeeper.core.sumOfVerifiedFiat import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.backup.main.list.Item import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.backup.BackupRepository import com.tonapps.wallet.data.passcode.PasscodeManager import com.tonapps.wallet.data.rates.RatesRepository @@ -27,12 +26,8 @@ import com.tonapps.wallet.data.staking.StakingRepository import com.tonapps.wallet.data.token.TokenRepository import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -112,7 +107,7 @@ class BackupViewModel( private suspend fun getBalanceType( balanceFiat: Coins, ): Int { - val rates = ratesRepository.getTONRates(settingsRepository.currency) + val rates = ratesRepository.getTONRates(wallet.network, settingsRepository.currency) val balanceTON = rates.convertFromFiat(TokenEntity.TON.address, balanceFiat) return BalanceType.getBalanceType(balanceTON) } @@ -124,7 +119,7 @@ class BackupViewModel( return if (wallet.testnet) { assets.first().fiat } else { - assets.map { it.fiat }.sumOf { it } + assets.sumOfVerifiedFiat() } } @@ -132,9 +127,9 @@ class BackupViewModel( wallet: WalletEntity, ): List { val currency = settingsRepository.currency - val tokens = tokenRepository.get(currency, wallet.accountId, wallet.testnet) ?: emptyList() - val staking = stakingRepository.get(wallet.accountId, wallet.testnet) - val staked = StakedEntity.create(wallet, staking, tokens, currency, ratesRepository, api) + val tokens = tokenRepository.get(currency, wallet.accountId, wallet.network) ?: emptyList() + val staking = stakingRepository.get(wallet.accountId, wallet.network) + val staked = StakedEntity.create(wallet, staking, tokens, currency, ratesRepository) val liquid = staked.find { it.isTonstakers }?.liquidToken val filteredTokens = if (liquid == null) tokens else tokens.filter { !liquid.token.address.contains(it.address) } return (filteredTokens.map { AssetsEntity.Token(it) } + staked.map { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryScreen.kt index 25142c665..1969b2e4c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryScreen.kt @@ -2,13 +2,13 @@ package com.tonapps.tonkeeper.ui.screen.battery import android.os.Bundle import android.view.View -import com.tonapps.tonkeeper.core.AnalyticsHelper +import com.tonapps.bus.core.AnalyticsHelper import com.tonapps.tonkeeper.koin.walletViewModel import com.tonapps.tonkeeper.ui.base.BaseHolderWalletScreen import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeper.ui.screen.battery.refill.BatteryRefillScreen import com.tonapps.tonkeeper.ui.screen.battery.settings.BatterySettingsScreen -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import kotlinx.coroutines.flow.map import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryViewModel.kt index 7ac98fe0f..37c8a9778 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/BatteryViewModel.kt @@ -1,27 +1,18 @@ package com.tonapps.tonkeeper.ui.screen.battery import android.app.Application -import android.util.Log import androidx.lifecycle.viewModelScope import com.tonapps.blockchain.ton.extensions.equalsAddress import com.tonapps.extensions.MutableEffectFlow import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.battery.recharge.BatteryRechargeScreen -import com.tonapps.tonkeeper.ui.screen.settings.main.SettingsViewModel -import com.tonapps.wallet.api.API import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.battery.BatteryMapper +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.battery.BatteryRepository -import com.tonapps.wallet.data.battery.entity.BatteryBalanceEntity import com.tonapps.wallet.data.settings.SettingsRepository import com.tonapps.wallet.data.token.TokenRepository -import com.tonapps.wallet.data.token.entities.AccountTokenEntity -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class BatteryViewModel( @@ -50,10 +41,10 @@ class BatteryViewModel( private fun openRecharge(jetton: String) { viewModelScope.launch { val rechargeToken = - batteryRepository.getRechargeMethodByJetton(wallet.testnet, jetton)?.jettonMaster + batteryRepository.getRechargeMethodByJetton(wallet.network, jetton)?.jettonMaster ?: "TON" val tokens = - tokenRepository.get(settingsRepository.currency, wallet.accountId, wallet.testnet) + tokenRepository.get(settingsRepository.currency, wallet.accountId, wallet.network) ?: return@launch val token = tokens.firstOrNull { it.address.equalsAddress(rechargeToken) } ?: return@launch @@ -78,7 +69,7 @@ class BatteryViewModel( batteryRepository.getBalance( tonProofToken = tonProofToken, publicKey = wallet.publicKey, - testnet = wallet.testnet, + network = wallet.network, ignoreCache = true ) } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeScreen.kt index 9ce61cf46..005956667 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeScreen.kt @@ -26,8 +26,8 @@ import com.tonapps.tonkeeper.ui.screen.send.main.SendContact import com.tonapps.tonkeeper.ui.screen.token.picker.TokenPickerScreen import com.tonapps.tonkeeperx.R import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.core.entity.SignRequestEntity import com.tonapps.wallet.data.token.entities.AccountTokenEntity import com.tonapps.wallet.localization.Localization @@ -170,11 +170,11 @@ class BatteryRechargeScreen(wallet: WalletEntity) : ) { allowedTokens, selectedToken -> navigation?.add( TokenPickerScreen.newInstance( - wallet = screenContext.wallet, - requestKey = tokenRequestKey, - selectedToken = selectedToken.balance.token, - allowedTokens = allowedTokens.map { it.address } - )) + wallet = screenContext.wallet, + requestKey = tokenRequestKey, + selectedToken = selectedToken.balance.token, + allowedTokens = allowedTokens.map { it.address } + )) }.launchIn(lifecycleScope) } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeViewModel.kt index 227f62496..cc400cacb 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/BatteryRechargeViewModel.kt @@ -1,37 +1,35 @@ package com.tonapps.tonkeeper.ui.screen.battery.recharge import android.app.Application -import android.net.Uri -import android.util.Log +import androidx.core.net.toUri import androidx.lifecycle.viewModelScope import com.tonapps.blockchain.ton.TonAddressTags import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.TonTransferHelper import com.tonapps.blockchain.ton.extensions.base64 -import com.tonapps.blockchain.ton.extensions.equalsAddress +import com.tonapps.blockchain.ton.extensions.equalsRawAddress import com.tonapps.blockchain.ton.extensions.isValidTonAddress +import com.tonapps.bus.core.AnalyticsHelper import com.tonapps.extensions.MutableEffectFlow -import com.tonapps.extensions.filterList import com.tonapps.extensions.state import com.tonapps.icu.Coins import com.tonapps.icu.CurrencyFormatter -import com.tonapps.tonkeeper.core.AnalyticsHelper -import com.tonapps.tonkeeper.core.entities.TransferEntity -import com.tonapps.tonkeeper.extensions.toGrams +import com.tonapps.blockchain.model.legacy.TransferEntity +import com.tonapps.blockchain.model.legacy.toGrams import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.battery.recharge.entity.BatteryRechargeEvent import com.tonapps.tonkeeper.ui.screen.battery.recharge.entity.RechargePackEntity import com.tonapps.tonkeeper.ui.screen.battery.recharge.entity.RechargePackType import com.tonapps.tonkeeper.ui.screen.battery.recharge.list.Item import com.tonapps.tonkeeper.ui.screen.battery.refill.entity.PromoState -import com.tonapps.tonkeeper.ui.screen.send.main.state.SendDestination +import com.tonapps.deposit.screens.send.state.SendDestination import com.tonapps.tonkeeper.ui.screen.send.transaction.SendTransactionScreen import com.tonapps.tonkeeperx.BuildConfig import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.battery.BatteryMapper import com.tonapps.wallet.data.battery.BatteryRepository import com.tonapps.wallet.data.battery.entity.BatteryBalanceEntity @@ -44,6 +42,7 @@ import com.tonapps.wallet.data.rates.RatesRepository import com.tonapps.wallet.data.settings.SettingsRepository import com.tonapps.wallet.data.token.TokenRepository import com.tonapps.wallet.data.token.entities.AccountTokenEntity +import io.tonapi.models.AccountStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async @@ -66,8 +65,6 @@ import kotlinx.coroutines.withContext import org.ton.block.AddrStd import uikit.extensions.collectFlow import java.math.BigDecimal -import androidx.core.net.toUri -import io.tonapi.models.AccountStatus class BatteryRechargeViewModel( app: Application, @@ -103,7 +100,7 @@ class BatteryRechargeViewModel( SendDestination.Empty } else { _destinationLoadingFlow.tryEmit(true) - val destination = getDestinationAccount(address, wallet.testnet) + val destination = getDestinationAccount(address, wallet.network) _destinationLoadingFlow.tryEmit(false) destination } @@ -148,8 +145,9 @@ class BatteryRechargeViewModel( val customAmount = selected.second val batteryBalance = getBatteryBalance(wallet) - val ton = tokenRepository.get(settingsRepository.currency, wallet.accountId, wallet.testnet) - ?.find { it.isTon } ?: return@combine emptyList() + val ton = tokenRepository.get(settingsRepository.currency, wallet.accountId, wallet.network) + ?.find { it.isTon } + ?: return@combine emptyList() val hasEnoughTonBalance = ton.balance.value >= Coins.of(0.1) val hasBatteryBalance = batteryBalance.balance > Coins.ZERO val rechargeMethod = getRechargeMethod(wallet, token) @@ -189,7 +187,7 @@ class BatteryRechargeViewModel( uiItems.addAll(uiItemsPacks(packs, selectedPackType, customAmount)) - if (BuildConfig.DEBUG || !api.config.batteryPromoDisable) { + if (BuildConfig.DEBUG || !api.getConfig(wallet.network).batteryPromoDisable) { uiItems.add(Item.Space) uiItems.add(Item.Promo(promoState)) } @@ -217,7 +215,9 @@ class BatteryRechargeViewModel( batteryConfig.chargeCost, amount.minus(calculateFiatFrom).coerceAtLeast(Coins.ZERO) ) + uiItems.add(Item.Space) + uiItems.add( Item.Amount( symbol = token.symbol, @@ -226,7 +226,7 @@ class BatteryRechargeViewModel( currency = token.symbol, value = remainingBalance ), formattedMinAmount = CurrencyFormatter.format( - currency = token.symbol, value = minAmount + currency = token.symbol, value = minAmount, scale = 3, // TODO maybe we shouldn't hardcode ), isInsufficientBalance = remainingBalance.isNegative, isLessThanMin = isLessThanMin, @@ -256,7 +256,7 @@ class BatteryRechargeViewModel( init { viewModelScope.launch(Dispatchers.IO) { - val appliedPromo = batteryRepository.getAppliedPromo(wallet.testnet) + val appliedPromo = batteryRepository.getAppliedPromo(wallet.network) if (appliedPromo.isNullOrBlank()) { promoStateFlow.tryEmit(PromoState.Default) @@ -307,13 +307,16 @@ class BatteryRechargeViewModel( } fun setToken(token: TokenEntity) { - supportedTokensFlow.take(1).filterList { - it.address.equalsAddress(token.address) - }.map { it.first() }.onEach { selectedToken -> - _tokenFlow.tryEmit(selectedToken) - _customAmountFlow.tryEmit(false) - _selectedPackTypeFlow.tryEmit(null) - }.launchIn(viewModelScope) + supportedTokensFlow.take(1) + .onEach { data -> + val result = data.firstOrNull { it.address.equalsRawAddress(token.address) } + if (result != null) { + _tokenFlow.tryEmit(result) + _customAmountFlow.tryEmit(false) + _selectedPackTypeFlow.tryEmit(null) + } + } + .launchIn(viewModelScope) } fun updateAddress(address: String) { @@ -341,7 +344,7 @@ class BatteryRechargeViewModel( val rechargeMethod = getRechargeMethod(wallet, token) val batteryBalance = getBatteryBalance(wallet) val config = getBatteryConfig(wallet) - val batteryMaxInputAmount = rechargeMethod.fromTon(api.config.batteryMaxInputAmount) + val batteryMaxInputAmount = rechargeMethod.fromTon(api.getConfig(wallet.network).batteryMaxInputAmount) val amount = _selectedPackTypeFlow.value?.let { packType -> rechargeMethod.fromTon( @@ -365,16 +368,14 @@ class BatteryRechargeViewModel( val fundReceiver = config.fundReceiver ?: return@combine val recipientAddress = if (destination is SendDestination.TonAccount) { destination.address - } else null + } else { + null + } val payload = wallet.contract.createBatteryBody( recipientAddress, - appliedPromo = batteryRepository.getAppliedPromo(wallet.testnet) + appliedPromo = batteryRepository.getAppliedPromo(wallet.network) ) - val validUntil = accountRepository.getValidUntil(wallet.testnet) - val network = when (wallet.testnet) { - true -> TonNetwork.TESTNET - false -> TonNetwork.MAINNET - } + val validUntil = accountRepository.getValidUntil(wallet.network) val forceRelayer = when { token.isTon -> false @@ -386,11 +387,13 @@ class BatteryRechargeViewModel( else -> false } - val account = api.resolveAccount(wallet.address, wallet.testnet) - val accountStatus = account?.status ?: AccountStatus.unknown + val account = api.resolveAccount(wallet.address, wallet.network) + val accountStatus = account?.status val stateInit = if (accountStatus == AccountStatus.nonexist || accountStatus == AccountStatus.uninit) { wallet.contract.stateInitCell() - } else null + } else { + null + } if (token.isTon) { val request = SignRequestEntity.Builder() @@ -405,14 +408,14 @@ class BatteryRechargeViewModel( payloadValue = payload.base64() ) ) - .setNetwork(network) + .setNetwork(wallet.network) .build("https://battery.tonkeeper.com/".toUri()) _eventFlow.tryEmit(BatteryRechargeEvent.Sign(request, forceRelayer)) } else { val queryId = TransferEntity.newWalletQueryId() val customPayload = if (token.isRequestMinting) { - api.getJettonCustomPayload(wallet.accountId, wallet.testnet, token.address) + api.getJettonCustomPayload(wallet.accountId, wallet.network, token.address) } else { null } @@ -438,7 +441,7 @@ class BatteryRechargeViewModel( payloadValue = jettonPayload.base64() ) ) - .setNetwork(network) + .setNetwork(wallet.network) .build("https://battery.tonkeeper.com/".toUri()) _eventFlow.tryEmit(BatteryRechargeEvent.Sign(request, forceRelayer)) @@ -485,7 +488,7 @@ class BatteryRechargeViewModel( private suspend fun getBatteryConfig( wallet: WalletEntity ): BatteryConfigEntity { - return batteryRepository.getConfig(wallet.testnet) + return batteryRepository.getConfig(wallet.network) } private suspend fun getBatteryBalance( @@ -494,7 +497,7 @@ class BatteryRechargeViewModel( val tonProofToken = accountRepository.requestTonProofToken(wallet) ?: return BatteryBalanceEntity.Empty return batteryRepository.getBalance( - tonProofToken = tonProofToken, publicKey = wallet.publicKey, testnet = wallet.testnet + tonProofToken = tonProofToken, publicKey = wallet.publicKey, network = wallet.network ) } @@ -502,7 +505,7 @@ class BatteryRechargeViewModel( return tokenRepository.get( currency = settingsRepository.currency, accountId = wallet.accountId, - testnet = wallet.testnet + network = wallet.network ) ?: emptyList() } @@ -517,6 +520,7 @@ class BatteryRechargeViewModel( it.jettonMaster } } + return tokens.filter { token -> supportTokenAddress.contains(token.address) }.sortedBy { it.fiat }.reversed() @@ -541,9 +545,9 @@ class BatteryRechargeViewModel( willBePaidManually: Boolean, shouldMinusReservedAmount: Boolean ): List { - val fiatRate = ratesRepository.getRates(settingsRepository.currency, token.address) + val fiatRate = ratesRepository.getRates(wallet.network, settingsRepository.currency, token.address) .getRate(token.address) - val config = api.config + val serverConfig = api.getConfig(wallet.network) return arrayOf( RechargePackType.LARGE, RechargePackType.MEDIUM, RechargePackType.SMALL @@ -553,7 +557,7 @@ class BatteryRechargeViewModel( rechargeMethod = rechargeMethod, fiatRate = fiatRate, token = token, - config = config, + config = serverConfig, shouldMinusReservedAmount = shouldMinusReservedAmount, willBePaidManually = willBePaidManually, currency = settingsRepository.currency, @@ -563,11 +567,11 @@ class BatteryRechargeViewModel( } private suspend fun getDestinationAccount( - userInput: String, testnet: Boolean + userInput: String, network: TonNetwork ) = withContext(Dispatchers.IO) { val addressTags = TonAddressTags.of(userInput) - val accountDeferred = async { api.resolveAccount(userInput, testnet) } - val publicKeyDeferred = async { api.safeGetPublicKey(userInput, testnet) } + val accountDeferred = async { api.resolveAccount(userInput, network) } + val publicKeyDeferred = async { api.safeGetPublicKey(userInput, network) } val account = accountDeferred.await() ?: return@withContext SendDestination.NotFound val publicKey = publicKeyDeferred.await() @@ -585,21 +589,21 @@ class BatteryRechargeViewModel( fun applyPromo(promo: String) { viewModelScope.launch(Dispatchers.IO) { if (promo.isEmpty()) { - batteryRepository.setAppliedPromo(wallet.testnet, null) + batteryRepository.setAppliedPromo(wallet.network, null) promoStateFlow.tryEmit(PromoState.Default) return@launch } promoStateFlow.tryEmit(PromoState.Loading) try { - if (api.batteryVerifyPurchasePromo(wallet.testnet, promo)) { - batteryRepository.setAppliedPromo(wallet.testnet, promo) + if (api.batteryVerifyPurchasePromo(wallet.network, promo)) { + batteryRepository.setAppliedPromo(wallet.network, promo) promoStateFlow.tryEmit(PromoState.Applied(promo)) } else { throw IllegalStateException("promo code is invalid") } } catch (_: Exception) { - batteryRepository.setAppliedPromo(wallet.testnet, null) + batteryRepository.setAppliedPromo(wallet.network, null) promoStateFlow.tryEmit(PromoState.Error) } } @@ -620,4 +624,4 @@ class BatteryRechargeViewModel( emit(boc) }.flowOn(Dispatchers.IO) -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/entity/RechargePackEntity.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/entity/RechargePackEntity.kt index 4e603f190..7c5e487d4 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/entity/RechargePackEntity.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/entity/RechargePackEntity.kt @@ -5,7 +5,7 @@ import com.tonapps.icu.CurrencyFormatter import com.tonapps.wallet.api.entity.ConfigEntity import com.tonapps.wallet.data.battery.entity.BatteryConfigEntity import com.tonapps.wallet.data.battery.entity.RechargeMethodEntity -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.settings.BatteryTransaction import com.tonapps.wallet.data.token.entities.AccountTokenEntity import java.math.BigDecimal @@ -86,9 +86,9 @@ data class RechargePackEntity( fun getTonAmount(meansFee: BigDecimal, type: RechargePackType): BigDecimal { return when (type) { - RechargePackType.LARGE -> meansFee.multiply(BigDecimal.valueOf(400)) - RechargePackType.MEDIUM -> meansFee.multiply(BigDecimal.valueOf(250)) - RechargePackType.SMALL -> meansFee.multiply(BigDecimal.valueOf(150)) + RechargePackType.LARGE -> meansFee.multiply(BigDecimal.valueOf(2000)) + RechargePackType.MEDIUM -> meansFee.multiply(BigDecimal.valueOf(1250)) + RechargePackType.SMALL -> meansFee.multiply(BigDecimal.valueOf(750)) } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AddressHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AddressHolder.kt index 8a1593be1..ab8ca94ab 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AddressHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AddressHolder.kt @@ -1,6 +1,5 @@ package com.tonapps.tonkeeper.ui.screen.battery.recharge.list.holder -import android.util.Log import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.AppCompatImageView @@ -8,7 +7,6 @@ import androidx.appcompat.widget.AppCompatTextView import com.tonapps.tonkeeper.extensions.clipboardText import com.tonapps.tonkeeper.ui.screen.battery.recharge.list.Item import com.tonapps.tonkeeperx.R -import uikit.extensions.hideKeyboard import uikit.widget.InputView import uikit.widget.RowLayout diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AmountHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AmountHolder.kt index 6a815d87e..44e017ba4 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AmountHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/AmountHolder.kt @@ -23,9 +23,9 @@ class AmountHolder( get() = amountView override fun onBind(item: Item.Amount) { - amountView.doOnValueChange = { it, _ -> - if (it != item.value) { - onValueChange(it) + amountView.doOnValueChange = { value, _ -> + if (value != item.value) { + onValueChange(value) } } amountView.suffix = item.symbol diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/RechargePackHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/RechargePackHolder.kt index 97a9cc860..4e5aefe74 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/RechargePackHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/recharge/list/holder/RechargePackHolder.kt @@ -59,7 +59,7 @@ class RechargePackHolder( private fun createDetails(transactions: Map): String { val builder = StringBuilder() - transactions.forEach { (transaction, count) -> + transactions.entries.forEachIndexed { index, (transaction, count) -> val titleRes = when (transaction) { BatteryTransaction.NFT -> Plurals.battery_nft BatteryTransaction.SWAP -> Plurals.battery_swap @@ -67,10 +67,10 @@ class RechargePackHolder( else -> throw IllegalArgumentException("Unsupported transaction type: $transaction") } builder.append(context.resources.getQuantityString(titleRes, count, count)) - if (transaction == BatteryTransaction.SWAP) { - builder.append(",") - } else if (transaction == BatteryTransaction.NFT) { + if (index == transactions.size - 2) { builder.append(" " + context.getString(Localization.or)) + } else if (index < transactions.size - 2) { + builder.append(",") } builder.append("\n") } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillScreen.kt index f4345b5a8..31b961dd0 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillScreen.kt @@ -9,7 +9,7 @@ import com.tonapps.tonkeeper.ui.screen.battery.BatteryScreen import com.tonapps.tonkeeper.ui.screen.battery.BatteryViewModel import com.tonapps.tonkeeper.ui.screen.battery.refill.list.Adapter import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import uikit.R diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillViewModel.kt index 4783b3e5b..04b805064 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/BatteryRefillViewModel.kt @@ -10,25 +10,24 @@ import com.tonapps.icu.CurrencyFormatter import com.tonapps.tonkeeper.Environment import com.tonapps.tonkeeper.billing.BillingManager import com.tonapps.tonkeeper.billing.priceFormatted -import com.tonapps.tonkeeper.core.AnalyticsHelper -import com.tonapps.tonkeeper.koin.remoteConfig +import com.tonapps.bus.core.AnalyticsHelper +import com.tonapps.log.L import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.battery.refill.entity.PromoState import com.tonapps.tonkeeper.ui.screen.battery.refill.list.Item -import com.tonapps.tonkeeperx.BuildConfig import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.api.API import com.tonapps.wallet.api.entity.ConfigEntity -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.battery.BatteryMapper import com.tonapps.wallet.data.battery.BatteryRepository import com.tonapps.wallet.data.battery.entity.BatteryBalanceEntity import com.tonapps.wallet.data.battery.entity.BatteryConfigEntity import com.tonapps.wallet.data.battery.entity.RechargeMethodEntity import com.tonapps.wallet.data.battery.entity.RechargeMethodType -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.rates.RatesRepository import com.tonapps.wallet.data.settings.BatteryTransaction import com.tonapps.wallet.data.settings.SettingsRepository @@ -63,12 +62,22 @@ class BatteryRefillViewModel( private val environment: Environment, private val analytics: AnalyticsHelper, ) : BaseWalletVM(app) { - + + companion object { + private val requiredTopupAssets = setOf( + TokenEntity.TON.address, + TokenEntity.USDT.address, + ) + } + + private val config: ConfigEntity + get() = api.getConfig(wallet.network) + private val isBatteryDisabled: Boolean - get() = api.config.flags.disableBattery + get() = config.flags.disableBattery private val isCryptoDisabled: Boolean - get() = api.config.disableBatteryCryptoRechargeModule + get() = config.disableBatteryCryptoRechargeModule private val _promoFlow = MutableStateFlow(null) private val promoFlow = _promoFlow.asStateFlow() @@ -101,7 +110,7 @@ class BatteryRefillViewModel( uiItems.add(uiItemBattery(batteryBalance, batteryConfig)) uiItems.add(Item.Space) - if (!api.config.batteryPromoDisable && !isBatteryDisabled) { + if (!config.batteryPromoDisable && !isBatteryDisabled) { uiItems.add(Item.Promo(promoState, promoCode)) uiItems.add(Item.Space) } @@ -111,22 +120,24 @@ class BatteryRefillViewModel( uiItems.add(Item.Space) } - if (environment.isGooglePlayServicesAvailable && !api.config.disableBatteryIapModule && !isBatteryDisabled) { + if (environment.isGooglePlayServicesAvailable && !config.disableBatteryIapModule && !isBatteryDisabled) { val tonPriceInUsd = - ratesRepository.getTONRates(WalletCurrency.USD).getRate(TokenEntity.TON.address) + ratesRepository.getTONRates(wallet.network, WalletCurrency.USD).getRate(TokenEntity.TON.address) if (tonPriceInUsd > Coins.ZERO) { - uiItems.addAll( - uiItemsPackages( - tonPriceInUsd = tonPriceInUsd, - batteryBalance = batteryBalance, - config = api.config, - products = iapProducts, - isProcessing = isProcessing, - batteryConfig = batteryConfig, - ) - ) - uiItems.add(Item.Space) + uiItemsPackages( + tonPriceInUsd = tonPriceInUsd, + batteryBalance = batteryBalance, + config = config, + products = iapProducts, + isProcessing = isProcessing, + batteryConfig = batteryConfig, + ).let { + if (it.isNotEmpty()) { + uiItems.addAll(it) + uiItems.add(Item.Space) + } + } } } @@ -141,7 +152,7 @@ class BatteryRefillViewModel( uiItems.add( Item.Refund( - wallet = wallet, refundUrl = "${api.config.batteryRefundEndpoint}?token=${ + wallet = wallet, refundUrl = "${config.batteryRefundEndpoint}?token=${ URLEncoder.encode( tonProofToken, "UTF-8" ) @@ -158,12 +169,12 @@ class BatteryRefillViewModel( init { viewModelScope.launch(Dispatchers.IO) { if (environment.isGooglePlayServicesAvailable) { - billingManager.loadProducts(api.config.iapPackages.map { it.productId }) + billingManager.loadProducts(config.iapPackages.map { it.productId }) } else { billingManager.setEmptyProducts() } - val appliedPromo = batteryRepository.getAppliedPromo(wallet.testnet) + val appliedPromo = batteryRepository.getAppliedPromo(wallet.network) if (appliedPromo.isNullOrBlank()) { _promoStateFlow.value = PromoState.Default @@ -175,10 +186,6 @@ class BatteryRefillViewModel( purchasesFlow.collectFlow(::handlePurchases) } - fun setPromo(promo: String) { - - } - private fun uiItemsPackages( tonPriceInUsd: Coins, batteryBalance: BatteryBalanceEntity, @@ -203,6 +210,10 @@ class BatteryRefillViewModel( meanFees = batteryConfig.chargeCost.toBigDecimal() ) + if (charges == 0) { + return@forEachIndexed + } + val transactions = mapOf( BatteryTransaction.SWAP to charges / batteryConfig.meanPrices.batteryMeanPriceSwap, BatteryTransaction.JETTON to charges / batteryConfig.meanPrices.batteryMeanPriceJetton, @@ -265,7 +276,7 @@ class BatteryRefillViewModel( private suspend fun getBatteryConfig( wallet: WalletEntity ): BatteryConfigEntity { - return batteryRepository.getConfig(wallet.testnet) + return batteryRepository.getConfig(wallet.network) } private suspend fun getBatteryBalance( @@ -274,7 +285,7 @@ class BatteryRefillViewModel( val tonProofToken = accountRepository.requestTonProofToken(wallet) ?: return BatteryBalanceEntity.Empty return batteryRepository.getBalance( - tonProofToken = tonProofToken, publicKey = wallet.publicKey, testnet = wallet.testnet + tonProofToken = tonProofToken, publicKey = wallet.publicKey, network = wallet.network ) } @@ -282,7 +293,7 @@ class BatteryRefillViewModel( return tokenRepository.get( currency = settingsRepository.currency, accountId = wallet.accountId, - testnet = wallet.testnet + network = wallet.network ) ?: emptyList() } @@ -297,8 +308,10 @@ class BatteryRefillViewModel( it.jettonMaster } } + return tokens.filter { token -> - supportTokenAddress.contains(token.address) && token.balance.value.isPositive + supportTokenAddress.contains(token.address) + && (token.balance.value.isPositive || token.address in requiredTopupAssets) }.sortedWith(compareByDescending { token -> token.isUsdt // Place USDT at the top }.thenBy { token -> @@ -316,19 +329,19 @@ class BatteryRefillViewModel( fun submitPromo(promo: String) { viewModelScope.launch(Dispatchers.IO) { if (promo.isEmpty()) { - batteryRepository.setAppliedPromo(wallet.testnet, null) + batteryRepository.setAppliedPromo(wallet.network, null) _promoStateFlow.value = PromoState.Default } else { _promoStateFlow.value = PromoState.Loading try { - if (api.batteryVerifyPurchasePromo(wallet.testnet, promo)) { - batteryRepository.setAppliedPromo(wallet.testnet, promo) + if (api.batteryVerifyPurchasePromo(wallet.network, promo)) { + batteryRepository.setAppliedPromo(wallet.network, promo) _promoStateFlow.value = PromoState.Applied(promo) } else { throw IllegalStateException("promo code is invalid") } } catch (_: Exception) { - batteryRepository.setAppliedPromo(wallet.testnet, null) + batteryRepository.setAppliedPromo(wallet.network, null) _promoStateFlow.value = PromoState.Error } } @@ -346,22 +359,32 @@ class BatteryRefillViewModel( AndroidBatteryPurchaseRequestPurchasesInner( productId = purchase.products.first(), token = purchase.purchaseToken, - promo = batteryRepository.getAppliedPromo(wallet.testnet) + promo = batteryRepository.getAppliedPromo(wallet.network) ) ) ) - api.battery(wallet.testnet).androidBatteryPurchase(tonProofToken, request) - batteryRepository.getBalance( - tonProofToken, wallet.publicKey, wallet.testnet, ignoreCache = true - ) - val promoCode = (_promoStateFlow.value as? PromoState.Applied)?.appliedPromo ?: "null" - withContext(Dispatchers.Main) { - analytics.batterySuccess("fiat", promoCode, "") + + val purchaseStatus = + api.battery(wallet.network).androidBatteryPurchase(tonProofToken, request) + + val firstTransaction = purchaseStatus.purchases.firstOrNull { it.productId == purchase.products.first() } + + if (firstTransaction != null && firstTransaction.success) { + batteryRepository.getBalance( + tonProofToken, wallet.publicKey, wallet.network, ignoreCache = true + ) + val promoCode = (_promoStateFlow.value as? PromoState.Applied)?.appliedPromo ?: "null" + withContext(Dispatchers.Main) { + analytics.batterySuccess("fiat", promoCode, "", null) + } + billingManager.consumeProduct(purchase.purchaseToken) + toast(Localization.battery_refilled) + } else { + toast(Localization.error) } - billingManager.consumeProduct(purchase.purchaseToken) } - toast(Localization.battery_refilled) } catch (e: Exception) { + L.e(e) toast(Localization.error) } finally { purchaseInProgress.tryEmit(false) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/Item.kt index fd3f3bae7..a56c92652 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/Item.kt @@ -5,7 +5,7 @@ import com.tonapps.wallet.api.entity.IAPPackageId import com.tonapps.tonkeeper.ui.screen.battery.refill.entity.PromoState import com.tonapps.uikit.list.BaseListItem import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.settings.BatteryTransaction import com.tonapps.wallet.data.token.entities.AccountTokenEntity diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/BatteryHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/BatteryHolder.kt index 54e9588cb..00e22bb76 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/BatteryHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/BatteryHolder.kt @@ -58,4 +58,4 @@ class BatteryHolder( subtitleView.text = spannableString } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/Holder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/Holder.kt index 45fe855a9..c8d9b6f4d 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/Holder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/Holder.kt @@ -1,6 +1,6 @@ package com.tonapps.tonkeeper.ui.screen.battery.refill.list.holder -import android.util.Log +import com.tonapps.log.L import android.view.MotionEvent import android.view.ViewGroup import androidx.annotation.LayoutRes diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/IAPPackHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/IAPPackHolder.kt index 973256f83..feba244ab 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/IAPPackHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/IAPPackHolder.kt @@ -78,7 +78,7 @@ class IAPPackHolder( private fun createDetails(transactions: Map): String { val builder = StringBuilder() - transactions.forEach { (transaction, count) -> + transactions.entries.forEachIndexed { index, (transaction, count) -> val titleRes = when (transaction) { BatteryTransaction.NFT -> Plurals.battery_nft BatteryTransaction.SWAP -> Plurals.battery_swap @@ -86,10 +86,10 @@ class IAPPackHolder( else -> throw IllegalArgumentException("Unsupported transaction type: $transaction") } builder.append(context.resources.getQuantityString(titleRes, count, count)) - if (transaction == BatteryTransaction.SWAP) { - builder.append(",") - } else if (transaction == BatteryTransaction.NFT) { + if (index == transactions.size - 2) { builder.append(" " + context.getString(Localization.or)) + } else if (index < transactions.size - 2) { + builder.append(",") } builder.append("\n") } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/SettingsHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/SettingsHolder.kt index d619daf55..0f276e438 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/SettingsHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/refill/list/holder/SettingsHolder.kt @@ -6,7 +6,6 @@ import androidx.appcompat.widget.AppCompatTextView import com.tonapps.tonkeeper.ui.screen.battery.refill.list.Item import com.tonapps.tonkeeperx.R import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.settings.BatteryTransaction import com.tonapps.wallet.localization.Localization import uikit.extensions.drawable @@ -24,26 +23,7 @@ class SettingsHolder( subtitleView.visibility = View.GONE } else { subtitleView.visibility = View.VISIBLE - subtitleView.text = context.getString( - Localization.battery_will_be_paid, - getSupportedTransactionText(item.supportedTransactions) - ) + subtitleView.text = context.getString(Localization.battery_refill_subtitle) } } - - private fun getSupportedTransactionText(supportedTransactions: Array): String { - return supportedTransactions.joinToString(", ") { - context.getString(supportedTransactionMap[it]!!) - } - } - - companion object { - - private val supportedTransactionMap = mapOf( - BatteryTransaction.NFT to Localization.battery_nft, - BatteryTransaction.SWAP to Localization.battery_swap, - BatteryTransaction.JETTON to Localization.battery_jetton, - ) - } - -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsScreen.kt index dffedb919..91c47769e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsScreen.kt @@ -9,7 +9,7 @@ import com.tonapps.tonkeeper.ui.screen.battery.BatteryScreen import com.tonapps.tonkeeper.ui.screen.battery.BatteryViewModel import com.tonapps.tonkeeper.ui.screen.battery.settings.list.Adapter import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import uikit.R diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsViewModel.kt index 39ac3e81c..d3865b632 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/BatterySettingsViewModel.kt @@ -7,7 +7,7 @@ import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.api.API import com.tonapps.wallet.api.entity.ConfigEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.battery.BatteryMapper import com.tonapps.wallet.data.battery.BatteryRepository import com.tonapps.wallet.data.battery.entity.BatteryBalanceEntity @@ -100,14 +100,14 @@ class BatterySettingsViewModel( return batteryRepository.getBalance( tonProofToken = tonProofToken, publicKey = wallet.publicKey, - testnet = wallet.testnet + network = wallet.network ) } private suspend fun getBatteryConfig( wallet: WalletEntity ): BatteryConfigEntity { - return batteryRepository.getConfig(wallet.testnet) + return batteryRepository.getConfig(wallet.network) } private fun getTransactionMeanPrice( diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/list/Item.kt index 34d98cf13..96d63cc33 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/battery/settings/list/Item.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.screen.battery.settings.list import com.tonapps.uikit.list.BaseListItem import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.settings.BatteryTransaction import com.tonapps.wallet.localization.Localization diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseScreen.kt index df3e19c6d..952b5ce60 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseScreen.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.ui.screen.browser.base import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import android.view.ViewGroup import androidx.core.view.ViewCompat @@ -17,7 +17,7 @@ import com.tonapps.tonkeeper.ui.screen.browser.main.BrowserMainScreen import com.tonapps.tonkeeper.ui.screen.browser.more.BrowserMoreScreen import com.tonapps.tonkeeper.ui.screen.browser.search.BrowserSearchScreen import com.tonapps.tonkeeperx.R -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import kotlinx.coroutines.launch import uikit.base.BaseFragment import uikit.drawable.FooterDrawable diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseViewModel.kt index 69f239ca3..9cfd51efa 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/base/BrowserBaseViewModel.kt @@ -6,7 +6,7 @@ import com.tonapps.extensions.MutableEffectFlow import com.tonapps.tonkeeper.Environment import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.BrowserRepository import com.tonapps.wallet.data.settings.SettingsRepository import kotlinx.coroutines.Dispatchers @@ -38,8 +38,8 @@ class BrowserBaseViewModel( suspend fun hasCategory(category: String): Boolean = withContext(Dispatchers.IO) { val categories = browserRepository.loadCategories( - country = environment.country, - testnet = wallet.testnet, + country = environment.deviceCountry, + network = wallet.network, locale = settingsRepository.getLocale() ) categories.any { it == category } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/confirm/DAppConfirmComposable.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/confirm/DAppConfirmComposable.kt deleted file mode 100644 index 5df8b0a98..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/confirm/DAppConfirmComposable.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.browser.confirm - -import android.net.Uri -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.localization.Localization -import ui.components.Checkbox -import ui.components.Header -import ui.components.TextHeader -import ui.components.button.TKButton -import ui.theme.Dimens -import ui.theme.Shapes -import ui.theme.UIKit - -@Composable -fun Disclaimer() { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(Shapes.medium) - .background(UIKit.colorScheme.field.background) - .padding(Dimens.offsetMedium) - ) { - Text( - modifier = Modifier.padding(end = Dimens.offsetLarge), - text = stringResource(id = Localization.dapp_disclaimer), - style = UIKit.typography.body2, - color = UIKit.colorScheme.text.primary, - ) - Icon( - painter = painterResource(id = UIKitIcon.ic_exclamationmark_circle_16), - contentDescription = null, - tint = UIKit.colorScheme.icon.secondary, - modifier = Modifier.align(Alignment.TopEnd) - ) - } -} - -@Composable -fun DAppIcon( - icon: Uri? -) { - Box(modifier = Modifier.padding(Dimens.offsetMedium)) { - Box( - modifier = Modifier - .size(96.dp) - .clip(RoundedCornerShape(24.dp)) - .background(UIKit.colorScheme.background.content), - contentAlignment = Alignment.Center - ) { - if (icon != null) { - AsyncImage( - model = icon, - modifier = Modifier.size(96.dp), - contentDescription = null - ) - } else { - Image( - painter = painterResource(id = UIKitIcon.ic_globe_56), - contentDescription = null, - colorFilter = ColorFilter.tint(UIKit.colorScheme.icon.secondary) - ) - } - } - } -} - -@Composable -fun DAppConfirmComposable( - host: String, - icon: Uri? = null, - name: String, - onOpen: () -> Unit, - onCheckedChange: (Boolean) -> Unit, - onFinishClick: () -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBars) - ) { - Header( - title = "", - actionIconRes = UIKitIcon.ic_close_16, - onActionClick = { onFinishClick() }, - ignoreSystemOffset = true, - showDivider = false, - backgroundColor = Color.Transparent - ) - - Column( - modifier = Modifier - .padding(horizontal = Dimens.offsetMedium) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - // DAppIcon(icon = icon) - Spacer(modifier = Modifier.height(Dimens.offsetMedium)) - Box(modifier = Modifier.padding(horizontal = Dimens.offsetMedium)) { - TextHeader( - title = name, - description = host, - ) - } - Spacer(modifier = Modifier.height(Dimens.offsetLarge)) - Disclaimer() - Spacer(modifier = Modifier.height(Dimens.offsetMedium)) - TKButton( - modifier = Modifier.fillMaxWidth(), - onClick = onOpen, - text = stringResource(id = Localization.open) - ) - Spacer(modifier = Modifier.height(Dimens.offsetMedium + 12.dp)) - - var isChecked by remember { mutableStateOf(false) } - - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = isChecked, - onCheckedChange = { - isChecked = it - onCheckedChange(it) - } - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - modifier = Modifier.clickable { isChecked = !isChecked }, - text = stringResource(id = Localization.do_not_show_again), - style = UIKit.typography.body1, - color = UIKit.colorScheme.text.secondary - ) - } - Spacer(modifier = Modifier.height(Dimens.offsetLarge)) - } - } -} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/confirm/DAppConfirmScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/confirm/DAppConfirmScreen.kt deleted file mode 100644 index 1c43db9ee..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/confirm/DAppConfirmScreen.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.browser.confirm - -import android.content.Intent -import android.net.Uri -import androidx.compose.runtime.Composable -import androidx.core.net.toUri -import com.tonapps.extensions.getParcelableCompat -import com.tonapps.tonkeeper.extensions.copyToClipboard -import com.tonapps.tonkeeper.extensions.toast -import com.tonapps.tonkeeper.koin.settingsRepository -import com.tonapps.tonkeeper.ui.base.BaseWalletVM -import com.tonapps.tonkeeper.ui.base.compose.ComposeWalletScreen -import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppScreen -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.dapps.entities.AppEntity -import com.tonapps.wallet.localization.Localization -import org.koin.androidx.viewmodel.ext.android.viewModel -import uikit.base.BaseFragment - -class DAppConfirmScreen(wallet: WalletEntity) : ComposeWalletScreen(wallet), - BaseFragment.Modal { - override val fragmentName: String = "DAppShareScreen" - - override val viewModel: BaseWalletVM.EmptyViewViewModel by viewModel() - - private val app: AppEntity - get() = arguments?.getParcelableCompat(ARG_APP)!! - - private val dAppUrl: Uri - get() = arguments?.getString(ARG_URL)!!.toUri() - - private fun openDApp() { - navigation?.add(DAppScreen.newInstance( - wallet = wallet, - title = app.name, - url = dAppUrl, - iconUrl = app.iconUrl, - source = "deep-link", - )) - finish() - } - - @Composable - override fun ScreenContent() { - DAppConfirmComposable( - host = dAppUrl.host ?: app.host, - icon = app.iconUrl.toUri(), - name = app.name, - onOpen = ::openDApp, - onCheckedChange = { checked -> - context?.settingsRepository?.setDAppOpenConfirm(wallet.id, app.host, !checked) - }, - onFinishClick = { finish() } - ) - } - - companion object { - private const val ARG_APP = "app" - private const val ARG_URL = "url" - - fun newInstance(wallet: WalletEntity, app: AppEntity, url: Uri): BaseFragment { - val screen = DAppConfirmScreen(wallet) - screen.putParcelableArg(ARG_APP, app) - screen.putStringArg(ARG_URL, url.toString()) - return screen - } - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppBridge.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppBridge.kt index 001d407c7..3dc56de9c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppBridge.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppBridge.kt @@ -1,6 +1,5 @@ package com.tonapps.tonkeeper.ui.screen.browser.dapp -import android.util.Log import com.tonapps.tonkeeper.manager.tonconnect.ConnectRequest import okhttp3.Headers import okhttp3.Response @@ -13,7 +12,7 @@ class DAppBridge( val deviceInfo: String, val isWalletBrowser: Boolean = true, val protocolVersion: Int = 2, - val send: suspend (array: JSONArray) -> JSONObject, + val send: suspend (array: JSONObject) -> JSONObject, val connect: suspend (protocolVersion: Int, request: ConnectRequest) -> JSONObject, val restoreConnection: suspend () -> JSONObject, val disconnect: suspend () -> Unit, @@ -31,7 +30,7 @@ class DAppBridge( override suspend fun invokeFunction(name: String, args: JSONArray): Any? { return when (name) { "connect" -> connect(protocolVersion, ConnectRequest.parse(args.getJSONObject(1))).toString() - "send" -> send(args).toString() + "send" -> send(args.getJSONObject(0)).toString() "restoreConnection" -> restoreConnection().toString() "disconnect" -> disconnect() "tonapi.fetch" -> { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppScreen.kt index fc2045727..1f72cd13e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppScreen.kt @@ -5,15 +5,12 @@ import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.os.Bundle -import android.util.Log import android.view.View import android.view.ViewGroup import android.webkit.PermissionRequest import android.webkit.WebChromeClient -import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse -import android.webkit.WebView import androidx.appcompat.widget.AppCompatTextView import androidx.core.app.ShareCompat import androidx.core.content.pm.ShortcutInfoCompat @@ -27,15 +24,14 @@ import androidx.lifecycle.lifecycleScope import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature -import com.tonapps.tonkeeper.deeplink.DeepLink -import com.tonapps.tonkeeper.deeplink.DeepLinkBuilder -import com.tonapps.tonkeeper.deeplink.DeepLinkRoute import com.tonapps.tonkeeper.extensions.copyToClipboard +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.tonapps.tonkeeper.extensions.setWallet import com.tonapps.tonkeeper.extensions.toast import com.tonapps.tonkeeper.extensions.withUtmSource import com.tonapps.tonkeeper.helper.BrowserHelper import com.tonapps.tonkeeper.koin.walletViewModel +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge import com.tonapps.tonkeeper.popup.ActionSheet import com.tonapps.tonkeeper.ui.base.InjectedTonConnectScreen import com.tonapps.tonkeeper.ui.component.TonConnectWebView @@ -44,12 +40,18 @@ import com.tonapps.tonkeeper.ui.screen.root.RootActivity import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.tabBarActiveIconColor import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.core.deeplink.DeepLink +import com.tonapps.core.deeplink.DeepLinkBuilder +import com.tonapps.core.deeplink.DeepLinkRoute +import com.tonapps.log.L import com.tonapps.wallet.data.dapps.DAppsRepository import com.tonapps.wallet.data.dapps.entities.AppConnectEntity import com.tonapps.wallet.data.dapps.entities.AppEntity import com.tonapps.wallet.localization.Localization +import java.io.ByteArrayInputStream import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.core.parameter.parametersOf import uikit.drawable.HeaderDrawable import uikit.extensions.collectFlow @@ -60,6 +62,8 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm override val fragmentName: String = "DAppScreen" + private val tonConnectBridge: ITonConnectBridge by inject() + private lateinit var headerDrawable: HeaderDrawable private lateinit var headerView: View private lateinit var backView: View @@ -94,6 +98,23 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm return overrideUrlLoading(request) } + override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? { + val uri = request.url + if (uri.scheme == "tg") { + webView.post { + if (consumeDeepLinkThrottle()) { + BrowserHelper.openTG(requireContext(), uri) + } + } + return WebResourceResponse( + "text/plain", + "utf-8", + ByteArrayInputStream(ByteArray(0)) + ) + } + return null + } + override fun onPageStarted(url: String, favicon: Bitmap?) { super.onPageStarted(url, favicon) refreshView.isRefreshing = true @@ -146,6 +167,9 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm } } + private val isForceConnect: Boolean + get() = args.forceConnect && webView.url?.toUri()?.host == args.url.host + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_CODE_FILE) { @@ -171,7 +195,7 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - analytics?.trackEventClickDApp( + analytics?.dappClick( url = args.url.toString(), name = args.title, source = args.source, @@ -184,26 +208,34 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm } private fun openNewTab(url: String) { - val now = System.currentTimeMillis() - if ((now - lastDeepLinkTime) > 1000) { - lastDeepLinkTime = now + if (consumeDeepLinkThrottle()) { + val uri = url.toUri() if (DeepLinkRoute.isAppLink(url)) { - val deeplink = DeepLink(url.toUri(), false, null) + val deeplink = DeepLink(uri, false, null) when (deeplink.route) { is DeepLinkRoute.DApp -> webView.loadUrl(url) is DeepLinkRoute.Unknown -> BrowserHelper.open(requireContext(), url) else -> processDeeplink(deeplink, url) } } else if (url.startsWith("https://x.com")) { - BrowserHelper.openX(requireActivity(), url.toUri()) - } else if (url.startsWith("https://t.me")) { - BrowserHelper.openTG(requireContext(), url.toUri()) + BrowserHelper.openX(requireActivity(), uri) + } else if (url.startsWith("https://t.me") || uri.scheme == "tg") { + BrowserHelper.openTG(requireContext(), uri) } else { - openNewDApp(url.toUri()) + openNewDApp(uri) } } } + private fun consumeDeepLinkThrottle(): Boolean { + val now = System.currentTimeMillis() + if ((now - lastDeepLinkTime) <= 1000) { + return false + } + lastDeepLinkTime = now + return true + } + private fun openNewDApp(uri: Uri) { navigation?.add( newInstance( @@ -246,16 +278,21 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm webView.settings.useWideViewPort = true webView.settings.loadWithOverviewMode = true webView.addCallback(webViewCallback) - webView.jsBridge = DAppBridge( - deviceInfo = deviceInfo.toString(), - send = { tonconnectSend(it, showLogout = !isForceConnect) }, - connect = { protocolVersion, request -> - tonconnect(protocolVersion, request, forceConnect = isForceConnect) - }, - restoreConnection = { viewModel.restoreConnection(currentUrl) }, - disconnect = { viewModel.disconnect() }, - tonapiFetch = ::tonapiFetch, + + val injector = tonConnectBridge.createInjector( + bridge = bridge, + wallet = wallet, ) + + if (!injector.inject(webView)) { + val error = "TonConnect injection failed (source=${args.source})" + L.e(error) + FirebaseCrashlytics.getInstance().recordException( + IllegalStateException(error) + ) + finish() + return + } webView.loadUrl(args.url.withUtmSource()) refreshView = view.findViewById(R.id.refresh) @@ -287,8 +324,18 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm } } - private val isForceConnect: Boolean - get() = args.forceConnect && webView.url?.toUri()?.host == args.url.host + private val bridge: DAppBridge by lazy { + DAppBridge( + deviceInfo = deviceInfo.toString(), + send = { tonconnectSend(it, showLogout = !isForceConnect) }, + connect = { protocolVersion, request -> + tonconnect(protocolVersion, request, forceConnect = isForceConnect) + }, + restoreConnection = { viewModel.restoreConnection(currentUrl) }, + disconnect = { viewModel.disconnect() }, + tonapiFetch = ::tonapiFetch, + ) + } private fun setDefaultState() { menuView.setOnClickListener { openDefaultMenu(it) } @@ -478,4 +525,4 @@ class DAppScreen(wallet: WalletEntity) : InjectedTonConnectScreen(R.layout.fragm return fragment } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppViewModel.kt index ae84c0716..d070ac9e4 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/dapp/DAppViewModel.kt @@ -4,10 +4,10 @@ import android.app.Application import android.net.Uri import com.tonapps.tonkeeper.extensions.getAppFixIcon import com.tonapps.tonkeeper.extensions.isDarkMode -import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge import com.tonapps.tonkeeper.ui.base.InjectedTonConnectScreen import com.tonapps.tonkeeper.worker.DAppPushToggleWorker -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.BrowserRepository import com.tonapps.wallet.data.dapps.DAppsRepository import com.tonapps.wallet.data.dapps.entities.AppEntity @@ -18,12 +18,12 @@ import kotlinx.coroutines.withContext class DAppViewModel( app: Application, private val wallet: WalletEntity, - private val tonConnectManager: TonConnectManager, + private val tonConnectBridge: ITonConnectBridge, override val url: Uri, private val dAppsRepository: DAppsRepository, private val settingsRepository: SettingsRepository, private val browserRepository: BrowserRepository -): InjectedTonConnectScreen.ViewModel(app, wallet, tonConnectManager) { +): InjectedTonConnectScreen.ViewModel(app, wallet, tonConnectBridge) { val isDarkTheme: Boolean get() = settingsRepository.theme.resId == uikit.R.style.Theme_App_Dark || context.isDarkMode diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainScreen.kt index 744fd78fd..d7ed3c4b9 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainScreen.kt @@ -24,7 +24,7 @@ import com.tonapps.tonkeeper.ui.screen.browser.main.list.explore.list.ExploreIte import com.tonapps.tonkeeper.ui.screen.browser.more.BrowserMoreScreen import com.tonapps.tonkeeper.ui.screen.country.CountryPickerScreen import com.tonapps.tonkeeperx.R -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.dapps.entities.AppEntity import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.flow.map @@ -159,11 +159,9 @@ class BrowserMainScreen(wallet: WalletEntity): WalletContextScreen(R.layout.frag collectFlow(insets, ::onApplyWindowInsets) } - val isDappsDisable = requireContext().remoteConfig?.isDappsDisable == true + exploreTabView.isVisible = !viewModel.isDappsDisabled - exploreTabView.isVisible = !isDappsDisable - - clickTab(if (isDappsDisable) connectedTabView else exploreTabView, animated = false) + clickTab(if (viewModel.isDappsDisabled) connectedTabView else exploreTabView, animated = false) } private fun onApplyWindowInsets(insets: WindowInsetsCompat) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainViewModel.kt index c2bfe6b5a..687cdbba5 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/BrowserMainViewModel.kt @@ -7,14 +7,13 @@ import androidx.core.net.toUri import androidx.lifecycle.viewModelScope import com.tonapps.extensions.mapList import com.tonapps.tonkeeper.Environment -import com.tonapps.tonkeeper.koin.remoteConfig -import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.browser.main.list.connected.ConnectedItem import com.tonapps.tonkeeper.ui.screen.browser.main.list.explore.list.ExploreItem import com.tonapps.tonkeeperx.BuildConfig import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.BrowserRepository import com.tonapps.wallet.data.browser.entities.BrowserAppEntity import com.tonapps.wallet.data.browser.entities.BrowserDataEntity @@ -30,7 +29,7 @@ class BrowserMainViewModel( private val wallet: WalletEntity, private val settings: SettingsRepository, private val api: API, - private val tonConnectManager: TonConnectManager, + private val tonConnectBridge: ITonConnectBridge, private val browserRepository: BrowserRepository, private val settingsRepository: SettingsRepository, private val environment: Environment @@ -39,30 +38,31 @@ class BrowserMainViewModel( val installId: String get() = settings.installId - val uiConnectedItemsFlow = tonConnectManager.walletAppsFlow(wallet).mapList { + val uiConnectedItemsFlow = tonConnectBridge.walletAppsFlow(wallet).mapList { ConnectedItem(wallet, it) } + val isDappsDisabled: Boolean + get() = api.getConfig(wallet.network).flags.disableDApps + private val _uiExploreItemsFlow = MutableStateFlow>(emptyList()) val uiExploreItemsFlow = _uiExploreItemsFlow.asStateFlow() init { - val isDappsDisable = context.remoteConfig?.isDappsDisable == true - - if (!isDappsDisable) { + if (!isDappsDisabled) { viewModelScope.launch(Dispatchers.IO) { - val code = environment.country + val code = environment.deviceCountry val locale = settingsRepository.getLocale() _uiExploreItemsFlow.value = emptyList() - browserRepository.load(code, wallet.testnet, locale)?.let { setData(it) } - browserRepository.loadRemote(code, wallet.testnet, locale)?.let { setData(it) } + browserRepository.load(code, wallet.network, locale)?.let { setData(it) } + browserRepository.loadRemote(code, wallet.network, locale)?.let { setData(it) } } } } fun showDisconnect(app: AppEntity) { viewModelScope.launch { - tonConnectManager.showLogoutAppBar(wallet, context, app.url) + tonConnectBridge.showLogoutAppBar(wallet, context, app.url) } } @@ -83,7 +83,7 @@ class BrowserMainViewModel( private fun setData(data: BrowserDataEntity) { val items = mutableListOf() if (data.apps.isNotEmpty()) { - items.add(ExploreItem.Banners(data.apps, api.config.featuredPlayInterval, wallet, environment.country)) + items.add(ExploreItem.Banners(data.apps, api.getConfig(wallet.network).featuredPlayInterval, wallet, environment.deviceCountry)) } var adsItem: ExploreItem.Ads? = null @@ -93,7 +93,7 @@ class BrowserMainViewModel( } else if (category.id == "ads" && category.apps.isNotEmpty()) { val ads = category.apps.first() if (ads.button != null) { - adsItem = ExploreItem.Ads(category.apps.first(), wallet) + adsItem = ExploreItem.Ads(category.apps.first(), wallet, country = environment.deviceCountry) } continue } @@ -119,7 +119,7 @@ class BrowserMainViewModel( app = app, wallet = wallet, singleLine = !isDigitalNomads, - country = environment.country + country = environment.deviceCountry )) } } @@ -136,7 +136,7 @@ class BrowserMainViewModel( app = app, wallet = wallet, singleLine = false, - country = environment.country + country = environment.deviceCountry )) } items.addAll(5, debugItems) @@ -144,4 +144,4 @@ class BrowserMainViewModel( _uiExploreItemsFlow.value = items.toList() } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/connected/ConnectedItem.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/connected/ConnectedItem.kt index 1dcd599bf..2c2c5deda 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/connected/ConnectedItem.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/connected/ConnectedItem.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.screen.browser.main.list.connected import android.net.Uri import com.tonapps.uikit.list.BaseListItem -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.dapps.entities.AppEntity data class ConnectedItem( diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/banners/BannerAppItem.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/banners/BannerAppItem.kt index 700c801c5..66a34d5d9 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/banners/BannerAppItem.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/banners/BannerAppItem.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.screen.browser.main.list.explore.banners import android.net.Uri import com.tonapps.uikit.list.BaseListItem -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.entities.BrowserAppEntity data class BannerAppItem( diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/ExploreItem.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/ExploreItem.kt index e3368905d..3eb7acc0d 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/ExploreItem.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/ExploreItem.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.ui.screen.browser.main.list.explore.list import android.net.Uri import androidx.core.net.toUri import com.tonapps.uikit.list.BaseListItem -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.entities.BrowserAppEntity sealed class ExploreItem(type: Int): BaseListItem(type) { @@ -23,7 +23,8 @@ sealed class ExploreItem(type: Int): BaseListItem(type) { data class Ads( val app: BrowserAppEntity, - val wallet: WalletEntity + val wallet: WalletEntity, + val country: String, ): ExploreItem(TYPE_ADS) { val button: BrowserAppEntity.Button diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/holder/ExploreAdsHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/holder/ExploreAdsHolder.kt index 1d3ec3eba..33e0f18f5 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/holder/ExploreAdsHolder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/main/list/explore/list/holder/ExploreAdsHolder.kt @@ -1,9 +1,13 @@ package com.tonapps.tonkeeper.ui.screen.browser.main.list.explore.list.holder -import android.util.Log +import com.tonapps.log.L import android.view.ViewGroup import android.widget.Button import androidx.appcompat.widget.AppCompatTextView +import androidx.core.net.toUri +import com.tonapps.core.deeplink.DeepLinkRoute +import com.tonapps.extensions.toUriOrNull +import com.tonapps.tonkeeper.helper.BrowserHelper.openDApp import com.tonapps.tonkeeper.ui.screen.browser.main.list.explore.list.ExploreItem import com.tonapps.tonkeeper.ui.screen.root.RootActivity import com.tonapps.tonkeeperx.R @@ -27,8 +31,16 @@ class ExploreAdsHolder(parent: ViewGroup): ExploreHolder(parent actionButton.text = item.button.title actionButton.setOnClickListener { - Log.d("ExploreAdsHolderLog", "url: ${item.uri}") - activity?.processDeepLink(item.uri, true, context.packageName) + L.d("ExploreAdsHolderLog", "url: ${item.uri}") + val result = item.button.payload.toUriOrNull() + ?.let { DeepLinkRoute.resolve(it) } + + if (result is DeepLinkRoute.DApp) { + item.app.copy(url = result.url.toUri()) + .openDApp(context, item.wallet, "ads", item.country) + } else { + activity?.processDeepLink(item.uri, true, context.packageName) + } } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreScreen.kt index 5346a90f2..fb24d2930 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreScreen.kt @@ -1,14 +1,14 @@ package com.tonapps.tonkeeper.ui.screen.browser.more import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import com.tonapps.tonkeeper.koin.walletViewModel import com.tonapps.tonkeeper.ui.base.BaseListWalletScreen import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeper.ui.screen.browser.base.BrowserBaseScreen import com.tonapps.tonkeeper.ui.screen.browser.more.list.Adapter -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.koin.core.parameter.parametersOf import uikit.base.BaseFragment import uikit.extensions.collectFlow diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreViewModel.kt index 7538685dc..6c1581cb4 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/BrowserMoreViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.browser.more.list.Item import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.BrowserRepository import com.tonapps.wallet.data.settings.SettingsRepository import kotlinx.coroutines.flow.SharingStarted @@ -23,7 +23,7 @@ class BrowserMoreViewModel( private val flow = browserRepository.dataFlow( country = settingsRepository.country, - testnet = wallet.testnet, + network = wallet.network, locale = settingsRepository.getLocale() ).map { it.categories }.map { categories -> categories.first { it.id == id } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/list/Item.kt index 10939cc52..f0eb2b959 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/more/list/Item.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.ui.screen.browser.more.list import android.net.Uri import com.tonapps.uikit.list.BaseListItem import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.browser.entities.BrowserAppEntity data class Item( diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeComposable.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeComposable.kt index 7c3d3c522..f68ae9449 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeComposable.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeComposable.kt @@ -1,6 +1,5 @@ package com.tonapps.tonkeeper.ui.screen.browser.safe -import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -18,16 +17,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.tonapps.uikit.icon.UIKitIcon import com.tonapps.wallet.localization.Localization -import ui.components.Header -import ui.components.TextHeader -import ui.components.button.TKButton -import ui.theme.ButtonColorsSecondary +import ui.components.moon.cell.MoonTextContentCell +import ui.components.moon.MoonAccentButton +import ui.components.moon.MoonTopAppBar +import ui.components.moon.ButtonColorsSecondary import ui.theme.Dimens -import ui.theme.UIKit @Composable fun DAppSafeComposable( @@ -43,7 +40,7 @@ fun DAppSafeComposable( verticalArrangement = Arrangement.spacedBy(Dimens.offsetMedium), horizontalAlignment = Alignment.CenterHorizontally ) { - Header( + MoonTopAppBar( title = "", actionIconRes = UIKitIcon.ic_close_16, onActionClick = onClose, @@ -58,21 +55,21 @@ fun DAppSafeComposable( contentDescription = null, ) - TextHeader( + MoonTextContentCell( title = stringResource(id = Localization.dapp_safe_modal_title), description = stringResource(id = Localization.dapp_safe_modal_subtitle), ) Spacer(modifier = Modifier.height(Dimens.offsetExtraSmall)) - TKButton( + MoonAccentButton( modifier = Modifier.fillMaxWidth(), onClick = onSafeClick, text = stringResource(id = Localization.dapp_safe_model_button), buttonColors = ButtonColorsSecondary ) - TKButton( + MoonAccentButton( modifier = Modifier.fillMaxWidth(), onClick = onClose, text = stringResource(id = Localization.close), diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeScreen.kt index b70963d93..8dcdde681 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/safe/DAppSafeScreen.kt @@ -4,7 +4,7 @@ import androidx.compose.runtime.Composable import com.tonapps.tonkeeper.helper.BrowserHelper import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.base.compose.ComposeWalletScreen -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.koin.androidx.viewmodel.ext.android.viewModel import uikit.base.BaseFragment diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchScreen.kt index 10c21df4f..b03daccbe 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchScreen.kt @@ -2,7 +2,6 @@ package com.tonapps.tonkeeper.ui.screen.browser.search import android.net.Uri import android.os.Bundle -import android.util.Log import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo @@ -15,9 +14,9 @@ import androidx.core.view.updateLayoutParams import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.core.deeplink.DeepLinkRoute import com.tonapps.extensions.toUriOrNull -import com.tonapps.tonkeeper.deeplink.DeepLink -import com.tonapps.tonkeeper.deeplink.DeepLinkRoute import com.tonapps.tonkeeper.helper.BrowserHelper import com.tonapps.tonkeeper.ui.base.WalletContextScreen import com.tonapps.tonkeeper.ui.screen.browser.dapp.DAppScreen @@ -25,10 +24,8 @@ import com.tonapps.tonkeeper.ui.screen.browser.safe.DAppSafeScreen import com.tonapps.tonkeeper.ui.screen.browser.search.list.Adapter import com.tonapps.tonkeeper.ui.screen.browser.search.list.Item import com.tonapps.tonkeeper.ui.screen.root.RootViewModel -import com.tonapps.tonkeeper.ui.screen.tonconnect.TonConnectSafeModeDialog import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.backgroundTransparentColor -import com.tonapps.wallet.data.account.entities.WalletEntity import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -38,7 +35,6 @@ import uikit.extensions.doKeyboardAnimation import uikit.extensions.focusWithKeyboard import uikit.extensions.getRootWindowInsetsCompat import uikit.extensions.hideKeyboard -import uikit.extensions.inflate import uikit.extensions.isMaxScrollReached import uikit.utils.RecyclerVerticalScrollListener import uikit.widget.HeaderView diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchViewModel.kt index 9978c7a38..3f7ea3ae4 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/search/BrowserSearchViewModel.kt @@ -2,7 +2,9 @@ package com.tonapps.tonkeeper.ui.screen.browser.search import android.app.Application import android.net.Uri -import android.util.Log +import androidx.core.net.toUri +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.core.deeplink.DeepLinkRoute import com.tonapps.extensions.MutableEffectFlow import com.tonapps.extensions.toUriOrNull import com.tonapps.network.get @@ -22,9 +24,6 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import org.json.JSONArray -import androidx.core.net.toUri -import com.tonapps.tonkeeper.deeplink.DeepLinkRoute -import com.tonapps.tonkeeper.extensions.isSafeModeEnabled class BrowserSearchViewModel( app: Application, @@ -60,7 +59,7 @@ class BrowserSearchViewModel( return@withContext emptyList() } - val isSafeModeEnabled = settingsRepository.isSafeModeEnabled(api) + val isSafeModeEnabled = settingsRepository.isSafeModeEnabled(TonNetwork.MAINNET) var uri = uri(query)?.let { DeepLinkRoute.normalize(it) } if (uri?.scheme == "tonkeeper") { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareComposable.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareComposable.kt index c7bd22185..e8b8ce335 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareComposable.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareComposable.kt @@ -28,17 +28,14 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import coil3.compose.AsyncImage import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.data.account.entities.WalletEntity import com.tonapps.wallet.localization.Localization -import ui.components.Header -import ui.components.TextHeader -import ui.components.button.TKButton -import ui.theme.ButtonColorsSecondary +import ui.components.moon.cell.MoonTextContentCell +import ui.components.moon.MoonAccentButton +import ui.components.moon.MoonTopAppBar +import ui.components.moon.ButtonColorsSecondary import ui.theme.Dimens import ui.theme.Shapes import ui.theme.UIKit @@ -141,7 +138,7 @@ fun DAppShareComposable( .fillMaxWidth() .windowInsetsPadding(WindowInsets.navigationBars) ) { - Header( + MoonTopAppBar( title = "", actionIconRes = UIKitIcon.ic_close_16, onActionClick = { onFinishClick() }, @@ -159,7 +156,7 @@ fun DAppShareComposable( DAppIcon(icon = icon) Spacer(modifier = Modifier.height(Dimens.offsetMedium)) Box(modifier = Modifier.padding(horizontal = Dimens.offsetMedium)) { - TextHeader( + MoonTextContentCell( title = stringResource(id = Localization.dapp_share_title, name), description = stringResource(id = Localization.dapp_share_subtitle, name), ) @@ -167,13 +164,13 @@ fun DAppShareComposable( Spacer(modifier = Modifier.height(Dimens.offsetLarge)) UrlView(url = url, onCopy = onCopy) Spacer(modifier = Modifier.height(Dimens.offsetMedium)) - TKButton( + MoonAccentButton( modifier = Modifier.fillMaxWidth(), onClick = onCopy, text = stringResource(id = Localization.copy_link) ) Spacer(modifier = Modifier.height(Dimens.offsetMedium)) - TKButton( + MoonAccentButton( modifier = Modifier.fillMaxWidth(), onClick = onShare, text = stringResource(id = Localization.share), diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareScreen.kt index adcb1a920..b81f759fe 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/browser/share/DAppShareScreen.kt @@ -4,14 +4,14 @@ import android.content.Intent import android.net.Uri import androidx.compose.runtime.Composable import androidx.core.net.toUri +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.core.deeplink.DeepLinkBuilder import com.tonapps.extensions.getParcelableCompat import com.tonapps.extensions.toUriOrNull -import com.tonapps.tonkeeper.deeplink.DeepLinkBuilder import com.tonapps.tonkeeper.extensions.copyToClipboard import com.tonapps.tonkeeper.extensions.toast import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.base.compose.ComposeWalletScreen -import com.tonapps.wallet.data.account.entities.WalletEntity import com.tonapps.wallet.data.dapps.entities.AppEntity import com.tonapps.wallet.localization.Localization import org.koin.androidx.viewmodel.ext.android.viewModel diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/camera/CameraScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/camera/CameraScreen.kt index ea05bb974..f5b6415e7 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/camera/CameraScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/camera/CameraScreen.kt @@ -11,13 +11,16 @@ import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.mlkit.vision.common.InputImage +import com.tonapps.blockchain.contract.Blockchain +import com.tonapps.blockchain.model.legacy.TokenEntity +import com.tonapps.blockchain.ton.TonNetwork import com.tonapps.blockchain.ton.extensions.isValidTonAddress import com.tonapps.blockchain.tron.isValidTronAddress +import com.tonapps.core.deeplink.DeepLink +import com.tonapps.core.deeplink.DeepLinkRoute import com.tonapps.extensions.getParcelableCompat import com.tonapps.extensions.toUriOrNull import com.tonapps.icu.Coins -import com.tonapps.tonkeeper.deeplink.DeepLink -import com.tonapps.tonkeeper.deeplink.DeepLinkRoute import com.tonapps.tonkeeper.extensions.toast import com.tonapps.tonkeeper.koin.analytics import com.tonapps.tonkeeper.ui.base.QRCameraScreen @@ -27,9 +30,7 @@ import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.constantWhiteColor import com.tonapps.uikit.color.stateList import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.value.Blockchain import com.tonapps.wallet.api.entity.QRScannerExtendsEntity -import com.tonapps.wallet.api.entity.TokenEntity import com.tonapps.wallet.data.account.AccountRepository import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.Dispatchers @@ -53,7 +54,7 @@ class CameraScreen : QRCameraScreen(R.layout.fragment_camera), BaseFragment.Bott private val accountRepository: AccountRepository by inject() private val api: API by inject() private val qrScannerExtends: List - get() = api.config.qrScannerExtends.filter { it.version == 1 } + get() = api.getConfig(TonNetwork.MAINNET).qrScannerExtends.filter { it.version == 1 } private val mode: CameraMode by lazy { requireArguments().getParcelableCompat(ARG_MODE)!! } private val chains: List by lazy { @@ -114,7 +115,7 @@ class CameraScreen : QRCameraScreen(R.layout.fragment_camera), BaseFragment.Bott val deeplink = DeepLink(DeepLink.fixBadUri(uri), true, null) val route = deeplink.route if (mode == CameraMode.Address && route is DeepLinkRoute.Transfer) { - rootViewModel.processTransferDeepLink(route) + rootViewModel.processTransferDeepLink(deeplink, route) finish() } else if (mode == CameraMode.TonConnect && route is DeepLinkRoute.TonConnect) { rootViewModel.processTonConnectDeepLink(deeplink, fromPackageName = null) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardBridge.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardBridge.kt index 0c115d5f4..ea8392b95 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardBridge.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardBridge.kt @@ -1,6 +1,7 @@ package com.tonapps.tonkeeper.ui.screen.card import com.tonapps.tonkeeper.manager.tonconnect.ConnectRequest +import com.tonapps.wallet.api.readBody import okhttp3.Headers import okhttp3.Response import org.json.JSONArray @@ -12,7 +13,7 @@ class CardBridge( val deviceInfo: String, val isWalletBrowser: Boolean = true, val protocolVersion: Int = 2, - val send: suspend (array: JSONArray) -> JSONObject, + val send: suspend (array: JSONObject) -> JSONObject, val connect: suspend (protocolVersion: Int, request: ConnectRequest) -> JSONObject, val restoreConnection: suspend () -> JSONObject, val disconnect: suspend () -> Unit, @@ -30,7 +31,7 @@ class CardBridge( override suspend fun invokeFunction(name: String, args: JSONArray): Any? { return when (name) { "connect" -> connect(protocolVersion, ConnectRequest.parse(args.getJSONObject(1))).toString() - "send" -> send(args).toString() + "send" -> send(args.getJSONObject(0)).toString() "restoreConnection" -> restoreConnection().toString() "disconnect" -> disconnect() "tonapi.fetch" -> { @@ -42,7 +43,7 @@ class CardBridge( } private fun webAPIResponse(response: Response): JSONObject { - val body = response.body?.string() ?: "" + val body = response.readBody() val json = JSONObject() json.put("body", body) json.put("ok", response.isSuccessful) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardScreen.kt index 35030d8aa..80332fd6d 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardScreen.kt @@ -28,7 +28,7 @@ import com.tonapps.tonkeeper.ui.component.TonConnectWebView import com.tonapps.tonkeeper.ui.screen.send.transaction.SendTransactionScreen import com.tonapps.tonkeeperx.R import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.core.entity.SignRequestEntity import org.json.JSONArray import org.json.JSONObject @@ -61,13 +61,15 @@ class CardScreen(wallet: WalletEntity): InjectedTonConnectScreen(R.layout.fragme webView.settings.useWideViewPort = true webView.settings.loadWithOverviewMode = true webView.addCallback(webViewCallback) - webView.jsBridge = CardBridge( - deviceInfo = deviceInfo.toString(), - send = ::tonconnectSend, - connect = ::tonconnect, - restoreConnection = { viewModel.restoreConnection(webView.url?.toUriOrNull()) }, - disconnect = { viewModel.disconnect() }, - tonapiFetch = ::tonapiFetch, + webView.setJsBridge( + CardBridge( + deviceInfo = deviceInfo.toString(), + send = ::tonconnectSend, + connect = ::tonconnect, + restoreConnection = { viewModel.restoreConnection(webView.url?.toUriOrNull()) }, + disconnect = { viewModel.disconnect() }, + tonapiFetch = ::tonapiFetch, + ) ) webView.loadUrl(viewModel.url.toString()) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardViewModel.kt index b115ba5da..e9a88cd41 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/card/CardViewModel.kt @@ -5,12 +5,12 @@ import android.net.Uri import com.tonapps.extensions.appVersionName import com.tonapps.extensions.filterList import com.tonapps.extensions.locale -import com.tonapps.tonkeeper.manager.tonconnect.TonConnectManager +import com.tonapps.tonkeeper.manager.tonconnect.ITonConnectBridge import com.tonapps.tonkeeper.manager.tonconnect.bridge.JsonBuilder import com.tonapps.tonkeeper.manager.tonconnect.bridge.model.BridgeError import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.base.InjectedTonConnectScreen -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.dapps.entities.AppConnectEntity import com.tonapps.wallet.data.settings.SettingsRepository import kotlinx.coroutines.flow.firstOrNull @@ -20,9 +20,9 @@ import org.json.JSONObject class CardViewModel( app: Application, private val wallet: WalletEntity, - private val tonConnectManager: TonConnectManager, + private val tonConnectBridge: ITonConnectBridge, private val settingsRepository: SettingsRepository, -): InjectedTonConnectScreen.ViewModel(app, wallet, tonConnectManager) { +): InjectedTonConnectScreen.ViewModel(app, wallet, tonConnectBridge) { override val url: Uri by lazy { val builder = Uri.parse("https://next.holders.io").buildUpon() diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesScreen.kt index 78b5d75a2..f868613ea 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesScreen.kt @@ -7,20 +7,19 @@ import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.tonapps.tonkeeper.core.AnalyticsHelper +import com.tonapps.deposit.screens.qr.QrAssetFragment +import com.tonapps.bus.core.AnalyticsHelper import com.tonapps.tonkeeper.extensions.isLightTheme import com.tonapps.tonkeeper.koin.walletViewModel import com.tonapps.tonkeeper.ui.base.UiListState import com.tonapps.tonkeeper.ui.screen.collectibles.main.list.Adapter import com.tonapps.tonkeeper.ui.screen.collectibles.manage.CollectiblesManageScreen import com.tonapps.tonkeeper.ui.screen.main.MainScreen -import com.tonapps.tonkeeper.ui.screen.qr.QRScreen import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.backgroundPageColor import com.tonapps.uikit.color.backgroundTransparentColor import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization import uikit.drawable.BarDrawable import uikit.extensions.collectFlow @@ -113,7 +112,7 @@ class CollectiblesScreen(wallet: WalletEntity): MainScreen.Child(R.layout.fragme } private fun openQRCode() { - navigation?.add(QRScreen.newInstance(screenContext.wallet)) + navigation?.add(QrAssetFragment.newInstance()) } private fun setEmptyState() { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesViewModel.kt index 19038a910..1fd92d777 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/CollectiblesViewModel.kt @@ -1,19 +1,18 @@ package com.tonapps.tonkeeper.ui.screen.collectibles.main import android.app.Application -import android.util.Log import androidx.lifecycle.viewModelScope import com.tonapps.blockchain.ton.extensions.toRawAddress import com.tonapps.extensions.flattenFirst import com.tonapps.network.NetworkMonitor import com.tonapps.tonkeeper.extensions.isSafeModeEnabled import com.tonapps.tonkeeper.extensions.with -import com.tonapps.tonkeeper.manager.tx.TransactionManager +import com.tonapps.wallet.data.tx.TransactionManager import com.tonapps.tonkeeper.ui.base.UiListState import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.collectibles.main.list.Item import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.CollectiblesRepository import com.tonapps.wallet.data.collectibles.entities.DnsExpiringEntity import com.tonapps.wallet.data.settings.SettingsRepository @@ -49,7 +48,7 @@ class CollectiblesViewModel( private val expiringDomainsFlow = flow { emit(collectiblesRepository.getDnsSoonExpiring( accountId = wallet.accountId, - testnet = wallet.testnet + network = wallet.network ).associateBy { it.addressRaw }) } @@ -97,9 +96,9 @@ class CollectiblesViewModel( hiddenBalances: Boolean, isOnline: Boolean, expiringDomains: Map - ): Flow = collectiblesRepository.getFlow(wallet.address, wallet.testnet, isOnline).map { result -> + ): Flow = collectiblesRepository.getFlow(wallet.address, wallet.network, isOnline).map { result -> hasNfts = result.list.isNotEmpty() - val safeMode = settingsRepository.isSafeModeEnabled(api) + val safeMode = settingsRepository.isSafeModeEnabled(wallet.network) val uiItems = mutableListOf() for (nft in result.list) { if (safeMode && !nft.verified) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/list/Item.kt index c30d9f521..3cc8f2278 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/main/list/Item.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.screen.collectibles.main.list import android.net.Uri import com.tonapps.uikit.list.BaseListItem -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.entities.NftEntity import com.tonapps.wallet.data.core.Trust @@ -33,7 +33,7 @@ sealed class Item(type: Int): BaseListItem(type) { get() = entity.collectionName val testnet: Boolean - get() = entity.testnet + get() = entity.network.isTestnet val verifier: Boolean get() = entity.verified @@ -47,4 +47,4 @@ sealed class Item(type: Int): BaseListItem(type) { data class Skeleton(val value: Boolean = true): Item(TYPE_SKELETON) -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageScreen.kt index de395af84..ae34d77da 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageScreen.kt @@ -5,11 +5,13 @@ import android.view.View import com.tonapps.tonkeeper.extensions.toast import com.tonapps.tonkeeper.koin.walletViewModel import com.tonapps.tonkeeper.ui.base.BaseListWalletScreen +import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeper.ui.screen.collectibles.manage.list.Adapter import com.tonapps.tonkeeper.ui.screen.collectibles.manage.list.Item -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization +import org.koin.core.parameter.parametersOf import uikit.base.BaseFragment import uikit.extensions.collectFlow @@ -17,54 +19,43 @@ class CollectiblesManageScreen(wallet: WalletEntity): BaseListWalletScreen) { - adapter.submitList(uiItems) { - if (spamArg) { - scrollToSpam() - spamArg = false - } - } - } - - private fun scrollToSpam() { - val index = adapter.currentList.indexOf(viewModel.spamItem) - if (index != -1) { - listView.scrollToPosition(index) - } + adapter.submitList(uiItems) } private fun showSpamDialog(item: Item.Collection) { spamDialog.show(item) { - viewModel.notSpam(item) + vm.notSpam(item) navigation?.toast(Localization.tx_marked_as_not_spam) } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageViewModel.kt index 234ad1d9d..43e5fca9f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/CollectiblesManageViewModel.kt @@ -1,65 +1,89 @@ package com.tonapps.tonkeeper.ui.screen.collectibles.manage import android.app.Application -import android.util.Log -import androidx.lifecycle.viewModelScope import com.tonapps.blockchain.ton.extensions.equalsAddress -import com.tonapps.extensions.MutableEffectFlow -import com.tonapps.extensions.filterList -import com.tonapps.extensions.mapList +import com.tonapps.mvi.AsyncViewModel import com.tonapps.tonkeeper.extensions.isSafeModeEnabled -import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.collectibles.manage.list.Item import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.api.API -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.CollectiblesRepository -import com.tonapps.wallet.data.collectibles.entities.NftCollectionEntity import com.tonapps.wallet.data.collectibles.entities.NftEntity -import com.tonapps.wallet.data.core.Trust import com.tonapps.wallet.data.settings.SettingsRepository import com.tonapps.wallet.data.settings.entities.TokenPrefsEntity.State import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +data class CollectiblesManageArgs( + val spamOnly: Boolean +) + class CollectiblesManageViewModel( - app: Application, + private val app: Application, private val wallet: WalletEntity, + private val args: CollectiblesManageArgs, private val collectiblesRepository: CollectiblesRepository, private val settingsRepository: SettingsRepository, - private val api: API, -): BaseWalletVM(app) { +) : AsyncViewModel() { - val spamItem = Item.Title(getString(Localization.spam)) + private val spamOnly: Boolean + get() = args.spamOnly private val safeMode: Boolean - get() = settingsRepository.isSafeModeEnabled(api) + get() = settingsRepository.isSafeModeEnabled(wallet.network) - private val _showedAllFlow = MutableStateFlow(false) - private val showedAllFlow = _showedAllFlow.asStateFlow() + private var showedAll = false + private var collectibles: List = emptyList() - private val _toggleFlow = MutableEffectFlow() - private val toggleFlow = _toggleFlow.asSharedFlow() + private val _uiItemsFlow = MutableStateFlow>(emptyList()) + val uiItemsFlow = _uiItemsFlow.asStateFlow() - private val collectiblesFlow = collectiblesRepository.getFlow( - address = wallet.address, - testnet = wallet.testnet, - isOnline = true - ).map { it.list } + init { + bgScope.launch { + collectiblesRepository.getFlow( + address = wallet.address, + network = wallet.network, + isOnline = true + ) + .map { it.list } + .collect { list -> + stateScope.launch { + collectibles = list + buildUiItems() + } + } + } + } + + fun showAll() { + stateScope.launch { + showedAll = true + buildUiItems() + } + } + + fun toggle(item: Item.Collection) { + stateScope.launch { + settingsRepository.setTokenHidden(wallet.id, item.address, item.visible) + if (!item.visible) showedAll = true + buildUiItems() + } + } + + fun notSpam(item: Item.Collection) { + stateScope.launch { + settingsRepository.setTokenState(wallet.id, item.address, State.TRUST) + showedAll = true + buildUiItems() + } + } - private val sortedCollectionFlow = combine( - collectiblesFlow, - toggleFlow, - ) { it, _ -> - val collectionItems = collectionItems(it) + private suspend fun buildUiItems() { + val collectionItems = collectionItems(collectibles) val visibleCollection = mutableListOf() val hiddenCollection = mutableListOf() @@ -82,58 +106,69 @@ class CollectiblesManageViewModel( } } - Triple(visibleCollection, hiddenCollection, spamCollection) - } - - val uiItemsFlow = combine( - sortedCollectionFlow, - showedAllFlow, - ){ (visibleCollection, hiddenCollection, spamCollection), showedAll -> val uiItems = mutableListOf() - if (visibleCollection.isNotEmpty()) { - uiItems.add(Item.Title(getString(Localization.visible))) - uiItems.add(Item.Space) - val showAllButton = visibleCollection.size > 3 && !showedAll && (hiddenCollection.size > 0 || spamCollection.size > 0) - val count = if (!showAllButton) visibleCollection.size else 3 - for ((index, item) in visibleCollection.withIndex()) { - val isLast = index == count - 1 - uiItems.add(item.copy( - position = ListCell.getPosition(count, index), - visible = true, - spam = false - )) - if (isLast && !showedAll) { - break + + if (!spamOnly) { + if (visibleCollection.isNotEmpty()) { + uiItems.add(Item.Title(app.getString(Localization.visible))) + uiItems.add(Item.Space) + + val showAllButton = + visibleCollection.size > 3 && !showedAll && hiddenCollection.isNotEmpty() + val count = if (!showAllButton) visibleCollection.size else 3 + for ((index, item) in visibleCollection.withIndex()) { + val isLast = index == count - 1 + uiItems.add( + item.copy( + position = ListCell.getPosition(count, index), + visible = true, + spam = false + ) + ) + + if (isLast && !showedAll) { + break + } } + + if (showAllButton) { + uiItems.add(Item.All) + } + + uiItems.add(Item.Space) } - if (showAllButton) { - uiItems.add(Item.All) - } - uiItems.add(Item.Space) - } - if (hiddenCollection.isNotEmpty()) { - uiItems.add(Item.Title(getString(Localization.hidden))) - uiItems.add(Item.Space) - for ((index, item) in hiddenCollection.withIndex()) { - uiItems.add(item.copy( - position = ListCell.getPosition(hiddenCollection.size, index), - visible = false, - spam = false - )) + if (hiddenCollection.isNotEmpty()) { + uiItems.add(Item.Title(app.getString(Localization.hidden))) + uiItems.add(Item.Space) + + for ((index, item) in hiddenCollection.withIndex()) { + uiItems.add( + item.copy( + position = ListCell.getPosition(hiddenCollection.size, index), + visible = false, + spam = false + ) + ) + } + + uiItems.add(Item.Space) } - uiItems.add(Item.Space) } if (spamCollection.isNotEmpty()) { - uiItems.add(spamItem) - uiItems.add(Item.Space) + if (!spamOnly) { + uiItems.add(Item.Title(app.getString(Localization.spam))) + uiItems.add(Item.Space) + } + for ((index, item) in spamCollection.withIndex()) { uiItems.add(item.copy( position = ListCell.getPosition(spamCollection.size, index), spam = true )) } + uiItems.add(Item.Space) } @@ -141,37 +176,12 @@ class CollectiblesManageViewModel( uiItems.add(Item.SafeMode(wallet)) } - uiItems.toList() - } - - init { - _toggleFlow.tryEmit(Unit) - } - - fun showAll() { - _showedAllFlow.value = true - } - - fun toggle(item: Item.Collection) { - viewModelScope.launch { - settingsRepository.setTokenHidden(wallet.id, item.address, item.visible) - _toggleFlow.tryEmit(Unit) - if (!item.visible) { - showAll() - } - } - } - - fun notSpam(item: Item.Collection) { - viewModelScope.launch { - settingsRepository.setTokenState(wallet.id, item.address, State.TRUST) - _toggleFlow.tryEmit(Unit) - showAll() - } + _uiItemsFlow.value = uiItems } private suspend fun collectionItems(collectibles: List): List { val items = mutableListOf() + for (nft in collectibles) { if (safeMode && !nft.verified) { continue @@ -195,7 +205,8 @@ class CollectiblesManageViewModel( ) } } - return items.toList() + + return items } private suspend fun isLocalSpam(nft: NftEntity): Boolean { @@ -204,21 +215,15 @@ class CollectiblesManageViewModel( } > 0 } - private suspend fun isLocalTrust(nft: NftEntity): Boolean { - return getStates(nft).count { - it == State.TRUST - } > 0 - } - private suspend fun getStates(nft: NftEntity): List { val states = mutableListOf( settingsRepository.getTokenPrefs(wallet.id, nft.address).state ) + nft.collection?.let { states.add(settingsRepository.getTokenPrefs(wallet.id, it.address).state) } - return states.toList() - } - -} \ No newline at end of file + return states + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/list/Item.kt index 515440500..ea566fb08 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/collectibles/manage/list/Item.kt @@ -3,7 +3,7 @@ package com.tonapps.tonkeeper.ui.screen.collectibles.manage.list import android.net.Uri import com.tonapps.uikit.list.BaseListItem import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity sealed class Item(type: Int): BaseListItem(type) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevScreen.kt index 4fd462670..cd5b68c5b 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevScreen.kt @@ -1,17 +1,25 @@ +@file:Suppress("DEPRECATION") + package com.tonapps.tonkeeper.ui.screen.dev +import android.content.Intent import android.os.Bundle import android.view.View import android.widget.Button import android.widget.EditText import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.AppCompatTextView +import androidx.core.app.ShareCompat import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.tonapps.extensions.locale +import com.tonapps.extensions.retrieveUri +import com.tonapps.log.L import com.tonapps.security.Security -import com.tonapps.tonkeeper.App +import com.tonapps.settings.dev.DevSettingsFragment +import com.tonapps.settings.dev.ROUTE_FEATURE_FLAGS +import com.tonapps.settings.dev.ROUTE_TOOLTIPS import com.tonapps.tonkeeper.core.DevSettings import com.tonapps.tonkeeper.extensions.copyToClipboard import com.tonapps.tonkeeper.extensions.showToast @@ -20,19 +28,17 @@ import com.tonapps.tonkeeper.manager.push.FirebasePush import com.tonapps.tonkeeper.ui.base.BaseWalletScreen import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeper.ui.screen.dev.list.launcher.LauncherAdapter -import com.tonapps.tonkeeper.view.TransactionDetailView import com.tonapps.tonkeeperx.BuildConfig import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.accentRedColor import com.tonapps.uikit.list.LinearLayoutManager -import com.tonapps.uikit.list.ListCell import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import uikit.base.BaseFragment -import uikit.dialog.alert.AlertDialog import uikit.extensions.collectFlow import uikit.widget.HeaderView import uikit.widget.item.ItemSwitchView +import uikit.widget.item.ItemTextView class DevScreen: BaseWalletScreen(R.layout.fragment_dev, ScreenContext.None), BaseFragment.BottomSheet { @@ -41,8 +47,11 @@ class DevScreen: BaseWalletScreen(R.layout.fragment_dev, Scr override val viewModel: DevViewModel by viewModel() private lateinit var iconsView: RecyclerView + private lateinit var tetraView: ItemSwitchView private lateinit var blurView: ItemSwitchView private lateinit var tonConnectLogsView: ItemSwitchView + private lateinit var logs: ItemSwitchView + private lateinit var shareLogs: ItemTextView private lateinit var importMnemonicAgainView: View private lateinit var logView: View private lateinit var logDataView: AppCompatEditText @@ -66,6 +75,14 @@ class DevScreen: BaseWalletScreen(R.layout.fragment_dev, Scr iconsView.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL) iconsView.adapter = LauncherAdapter() + tetraView = view.findViewById(R.id.tetra) + tetraView.setChecked(DevSettings.tetraEnabled, false) + tetraView.doOnCheckedChanged = { isChecked, byUser -> + if (byUser) { + DevSettings.tetraEnabled = isChecked + } + } + dnsAllView = view.findViewById(R.id.dns_all) dnsAllView.setChecked(DevSettings.dnsAll, false) dnsAllView.doOnCheckedChanged = { isChecked, byUser -> @@ -102,6 +119,49 @@ class DevScreen: BaseWalletScreen(R.layout.fragment_dev, Scr } } + logs = view.findViewById(R.id.logs) + logs.setChecked(DevSettings.isLogsEnabled, false) + logs.doOnCheckedChanged = { isChecked, byUser -> + if (byUser) { + DevSettings.isLogsEnabled = isChecked + L.setTargets(L.defaultTargets(requireContext(), isChecked)) + } + } + + shareLogs = view.findViewById(R.id.share_logs) + shareLogs.setOnClickListener { + if (!L.hasLogs()) { + navigation?.toast("No logs found!") + return@setOnClickListener + } + + L.capture { file -> + val context = requireContext() + lifecycleScope.launch { + DevSettings.isLogsEnabled = false + L.setTargets(L.defaultTargets(context, false)) + + ShareCompat.IntentBuilder(context) + .run { + val uri = retrieveUri(context, file ?: return@launch) + setStream(uri) + + intent.apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, "Share logs") + + setDataAndType(uri, "*/*") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + setChooserTitle("Share logs") + startChooser() + } + } + } + } + + importMnemonicAgainView = view.findViewById(R.id.import_mnemonic_again) importMnemonicAgainView.setOnClickListener { importMnemonicAgain(false) } importMnemonicAgainView.setOnLongClickListener { importMnemonicAgain(true); true } @@ -150,7 +210,30 @@ class DevScreen: BaseWalletScreen(R.layout.fragment_dev, Scr copyFirebasePushToken() } + view.findViewById(R.id.copy_install_id).setOnClickListener { + requireContext().copyToClipboard(viewModel.installId, true) + navigation?.toast("Install ID copied to clipboard") + } + logCopy = view.findViewById(R.id.log_copy) + + view.findViewById(R.id.feature_flags).apply { +// if (!BuildConfig.DEBUG) { +// visibility = View.GONE +// } + setOnClickListener { + navigation?.add(DevSettingsFragment.newInstance(ROUTE_FEATURE_FLAGS)) + } + } + + view.findViewById(R.id.tooltips).apply { + if (!BuildConfig.DEBUG) { + visibility = View.GONE + } + setOnClickListener { + navigation?.add(DevSettingsFragment.newInstance(ROUTE_TOOLTIPS)) + } + } } private fun copyFirebasePushToken() { @@ -230,4 +313,4 @@ class DevScreen: BaseWalletScreen(R.layout.fragment_dev, Scr fun newInstance() = DevScreen() } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevViewModel.kt index 9e823ce7c..e73b7050c 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dev/DevViewModel.kt @@ -4,16 +4,18 @@ import android.app.Application import android.widget.Toast import androidx.lifecycle.viewModelScope import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.tonapps.blockchain.ton.TonNetwork +import com.tonapps.core.requestVault import com.tonapps.extensions.bestMessage import com.tonapps.tonkeeper.Environment import com.tonapps.tonkeeper.core.DevSettings -import com.tonapps.tonkeeper.extensions.requestVault import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.card.CardScreen import com.tonapps.wallet.api.API import com.tonapps.wallet.data.account.AccountRepository import com.tonapps.wallet.data.dapps.DAppsRepository import com.tonapps.wallet.data.rn.RNLegacy +import com.tonapps.wallet.data.settings.SettingsRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -24,7 +26,6 @@ import org.json.JSONArray import org.json.JSONObject import org.ton.mnemonic.Mnemonic import uikit.extensions.activity -import java.util.Locale class DevViewModel( app: Application, @@ -33,8 +34,12 @@ class DevViewModel( private val dAppsRepository: DAppsRepository, private val environment: Environment, private val api: API, + private val settingsRepository: SettingsRepository ): BaseWalletVM(app) { + val installId: String + get() = settingsRepository.installId + val debugCountryFlow = environment.countryDataFlow.map { val lines = mutableListOf() lines.add("Store: ${it.fromStore ?: "unknown"}") @@ -69,7 +74,7 @@ class DevViewModel( DevSettings.country = country?.uppercase() environment.setDebugCountry(DevSettings.country) viewModelScope.launch { - api.refreshConfig(false) + api.refreshConfig() } } @@ -129,7 +134,7 @@ class DevViewModel( if (tcApps.mainnet.isNotEmpty()) { for (apps in tcApps.mainnet) { - dAppsRepository.migrationFromLegacy(apps, false) + dAppsRepository.migrationFromLegacy(apps, TonNetwork.MAINNET) lines.add("Mainnet app imported: ${apps.address}") } } @@ -138,7 +143,7 @@ class DevViewModel( lines.add("Testnet apps found: ${tcApps.testnet.size}") if (tcApps.testnet.isNotEmpty()) { for (apps in tcApps.testnet) { - dAppsRepository.migrationFromLegacy(apps, true) + dAppsRepository.migrationFromLegacy(apps, TonNetwork.TESTNET) lines.add("Testnet app imported: ${apps.address}") } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewScreen.kt index 5670e9fb2..e87691da2 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewScreen.kt @@ -13,7 +13,7 @@ import com.tonapps.tonkeeper.ui.screen.dns.renew.list.Adapter import com.tonapps.tonkeeper.ui.screen.dns.renew.list.Item import com.tonapps.tonkeeper.ui.screen.root.RootViewModel import com.tonapps.tonkeeperx.R -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.entities.DnsExpiringEntity import com.tonapps.wallet.localization.Localization import org.koin.androidx.viewmodel.ext.android.activityViewModel diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewViewModel.kt index 27d20c3f0..ab4b528af 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/DNSRenewViewModel.kt @@ -4,35 +4,27 @@ import android.app.Application import android.net.Uri import androidx.lifecycle.viewModelScope import com.tonapps.blockchain.ton.TONOpCode -import com.tonapps.blockchain.ton.extensions.base64 import com.tonapps.blockchain.ton.extensions.storeOpCode import com.tonapps.blockchain.ton.extensions.storeQueryId import com.tonapps.extensions.currentTimeSeconds -import com.tonapps.extensions.filterList import com.tonapps.icu.Coins -import com.tonapps.tonkeeper.core.entities.TransferEntity +import com.tonapps.blockchain.model.legacy.TransferEntity import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.dns.renew.list.Item import com.tonapps.tonkeeper.ui.screen.send.transaction.SendTransactionScreen import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.CollectiblesRepository import com.tonapps.wallet.data.collectibles.entities.DnsExpiringEntity import com.tonapps.wallet.data.core.entity.RawMessageEntity import com.tonapps.wallet.data.core.entity.SignRequestEntity import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch import org.ton.cell.CellBuilder -import uikit.extensions.collectFlow class DNSRenewViewModel( app: Application, @@ -43,11 +35,11 @@ class DNSRenewViewModel( ) : BaseWalletVM(app) { private val dnsExpiringFlow = flow { - emit(collectiblesRepository.getDnsExpiring(wallet.accountId, wallet.testnet, 366)) + emit(collectiblesRepository.getDnsExpiring(wallet.accountId, wallet.network, 366)) }.stateIn(viewModelScope, SharingStarted.Eagerly, entities) val uiItemsFlow = dnsExpiringFlow.map { - val items = collectiblesRepository.getDnsExpiring(wallet.accountId, wallet.testnet, 366) + val items = collectiblesRepository.getDnsExpiring(wallet.accountId, wallet.network, 366) val uiItems = items.mapIndexed { index, dnsExpiringEntity -> Item( position = ListCell.getPosition(items.size, index), diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/Item.kt index 421fe9668..e95009812 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/Item.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/Item.kt @@ -2,7 +2,7 @@ package com.tonapps.tonkeeper.ui.screen.dns.renew.list import com.tonapps.uikit.list.BaseListItem import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.entities.DnsExpiringEntity import com.tonapps.wallet.data.collectibles.entities.NftEntity diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/holder/Holder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/holder/Holder.kt index a08883bb4..c7504f92f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/holder/Holder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/dns/renew/list/holder/Holder.kt @@ -10,7 +10,7 @@ import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.UIKitColor import com.tonapps.uikit.color.resolveColor import com.tonapps.uikit.list.BaseListHolder -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.entities.NftEntity import com.tonapps.wallet.localization.Localization import uikit.extensions.drawable diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/TxScope.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/TxScope.kt index 7d6d70ca0..f5e239642 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/TxScope.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/TxScope.kt @@ -1,10 +1,11 @@ package com.tonapps.tonkeeper.ui.screen.events.compose +import com.tonapps.log.L import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.dialog.encrypted.EncryptedCommentScreen import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.events.CommentEncryption +import com.tonapps.blockchain.model.legacy.WalletEntity +import com.tonapps.blockchain.model.legacy.CommentEncryption import com.tonapps.wallet.data.events.EventsRepository import com.tonapps.wallet.data.events.tx.model.TxEvent import com.tonapps.wallet.data.passcode.PasscodeManager @@ -27,7 +28,7 @@ object TxScope { try { val action = tx.actions[actionIndex] val encryptedText = action.encryptedText ?: return true - val account = action.account ?: throw Exception("No account") + val sender = action.sender ?: throw Exception("No sender account") if (settingsRepository.showEncryptedCommentModal) { val noShowAgain = withContext(Dispatchers.Main) { EncryptedCommentScreen.show(context) @@ -43,12 +44,13 @@ object TxScope { wallet.publicKey, privateKey, encryptedText.cipher, - account.address + sender.address ) eventsRepository.saveDecryptedComment(tx.hash, decrypted) return false - } catch (_: Throwable) { + } catch (e: Throwable) { + L.e(e) return false } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsScreen.kt index 5fad8e48d..d3262bce8 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsScreen.kt @@ -5,7 +5,7 @@ import com.tonapps.extensions.getParcelableCompat import com.tonapps.tonkeeper.koin.walletViewModel import com.tonapps.tonkeeper.ui.base.compose.ComposeWalletScreen import com.tonapps.tonkeeper.ui.screen.events.compose.details.ui.TxDetailsComposable -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.events.tx.model.TxEvent import org.koin.core.parameter.parametersOf import uikit.base.BaseFragment diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsViewModel.kt index 4618fe120..b89084a37 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/TxDetailsViewModel.kt @@ -1,18 +1,18 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.details import android.app.Application -import android.util.Log import androidx.lifecycle.viewModelScope +import com.tonapps.blockchain.contract.Blockchain import com.tonapps.blockchain.ton.extensions.toUserFriendly +import com.tonapps.core.extensions.iconExternalUrl import com.tonapps.extensions.withApproximately import com.tonapps.extensions.withMinus import com.tonapps.extensions.withPlus import com.tonapps.icu.CurrencyFormatter import com.tonapps.tonkeeper.api.shortAddress -import com.tonapps.tonkeeper.core.history.nameRes +import com.tonapps.wallet.features.events.nameRes import com.tonapps.tonkeeper.extensions.composeIcon import com.tonapps.tonkeeper.extensions.copyWithToast -import com.tonapps.tonkeeper.extensions.iconExternalUrl import com.tonapps.tonkeeper.helper.BrowserHelper import com.tonapps.tonkeeper.helper.DateHelper import com.tonapps.tonkeeper.ui.base.BaseWalletVM @@ -23,14 +23,12 @@ import com.tonapps.tonkeeper.ui.screen.transaction.CommentReportDialog import com.tonapps.uikit.color.accentGreenColor import com.tonapps.uikit.icon.UIKitIcon import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.value.Blockchain import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.CollectiblesRepository -import com.tonapps.wallet.data.core.currency.WalletCurrency +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.wallet.data.events.ActionType import com.tonapps.wallet.data.events.EventsRepository -import com.tonapps.wallet.data.events.tx.model.TxAction import com.tonapps.wallet.data.events.tx.model.TxActionBody import com.tonapps.wallet.data.events.tx.model.TxEvent import com.tonapps.wallet.data.passcode.PasscodeManager @@ -185,13 +183,17 @@ class TxDetailsViewModel( } private suspend fun updateData() { - val rates = ratesRepository.getRates(currency, action.tokens.map { it.address }) + val rates = ratesRepository.getRates(wallet.network, currency, action.tokens.map { it.address }) val rateAmount = if (!isUsdt && primaryValue != null) { val value = rates.convert(primaryValue.currency.code, primaryValue.value) if (value.isPositive) { CurrencyFormatter.formatFiat(currency.symbol, value) - } else null - } else null + } else { + null + } + } else { + null + } _uiStateFlow.update { uiState -> uiState.copy( @@ -247,7 +249,9 @@ class TxDetailsViewModel( Plurals.battery_charges, charges, charges ) ) - } else null + } else { + null + } } is TxEvent.Extra.Refund -> { val rate = rates?.convertTON(extra.value) @@ -368,7 +372,9 @@ class TxDetailsViewModel( val iconUrl = currency.iconExternalUrl(context) ?: return@mapNotNull null val subicon = if (currency.isUSDT) { currency.chain.iconExternalUrl(context) - } else null + } else { + null + } UiState.Icon( url = iconUrl, subicon = subicon @@ -413,7 +419,8 @@ class TxDetailsViewModel( ) fun openTx() { - val url = api.config.formatTransactionExplorer(wallet.testnet, tx.blockchain == Blockchain.TRON, txId) + val url = api.getConfig(wallet.network) + .formatTransactionExplorer(wallet.testnet, tx.blockchain == Blockchain.TRON, txId) BrowserHelper.open(context, url) } @@ -427,9 +434,9 @@ class TxDetailsViewModel( try { val nft = collectiblesRepository.getNft( accountId = wallet.accountId, - testnet = wallet.testnet, + network = wallet.network, address = nftAddress - ) ?: throw Throwable() + ) ?: throw IllegalStateException("NFT not found") openScreen(NftScreen.newInstance(wallet, nft)) } catch (e: Throwable) { toast(Localization.unknown_error) @@ -475,7 +482,7 @@ class TxDetailsViewModel( viewModelScope.launch { settingsRepository.setSpamStateTransaction(wallet.id, txId, SpamTransactionState.NOT_SPAM) - eventsRepository.removeSpam(wallet.accountId, wallet.testnet, txId) + eventsRepository.removeSpam(wallet.accountId, wallet.network, txId) toast(Localization.tx_marked_as_not_spam) updateUiActionItems() @@ -500,7 +507,7 @@ class TxDetailsViewModel( comment = comment, recipient = wallet.accountId ) - eventsRepository.markAsSpam(wallet.accountId, wallet.testnet, txId) + eventsRepository.markAsSpam(wallet.accountId, wallet.network, txId) loading(false) toast(Localization.tx_marked_as_spam) } catch (ignored: Throwable) { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxActionsMenu.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxActionsMenu.kt index 59954099e..e54138679 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxActionsMenu.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxActionsMenu.kt @@ -8,7 +8,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.tonapps.tonkeeper.ui.screen.events.compose.details.TxDetailsViewModel -import ui.components.ActionButtonIcon +import com.tonapps.uikit.icon.UIKitIcon +import ui.components.moon.MoonActionIcon import ui.components.popup.ActionMenu import ui.painterResource @@ -20,8 +21,8 @@ fun TxActionsMenu( var expanded by remember { mutableStateOf(false) } Box { - ActionButtonIcon( - painter = painterResource(com.tonapps.tonkeeperx.R.drawable.ic_ellipsis_16), + MoonActionIcon( + painter = painterResource(UIKitIcon.ic_ellipsis_16), onClick = { expanded = !expanded }, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsComposable.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsComposable.kt index d5f7a55aa..1226e70fe 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsComposable.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsComposable.kt @@ -3,10 +3,7 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.details.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -17,16 +14,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.rememberNestedScrollInteropConnection -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tonapps.tonkeeper.ui.screen.events.compose.details.TxDetailsViewModel import com.tonapps.tonkeeper.ui.screen.events.compose.details.state.UiState -import com.tonapps.wallet.localization.Localization import ui.animation.ContentCrossfade -import ui.components.modal.TKModalScaffold import ui.components.details.TKDetails import ui.components.details.TKDetailsInfo -import ui.components.image.AsyncImage +import ui.components.modal.TKModalScaffold +import ui.components.moon.MoonAsyncImage import ui.theme.Dimens import ui.theme.Shapes @@ -35,7 +30,6 @@ fun TxDetailsComposable( viewModel: TxDetailsViewModel, onCloseClick: () -> Unit ) { - val uiState by viewModel.uiStateFlow.collectAsState() val spam by remember { derivedStateOf { uiState.spam } } val interop = rememberNestedScrollInteropConnection() @@ -71,13 +65,12 @@ fun TxDetailsComposable( if (spam == UiState.Spam.Spam) { TxSpamBadge() } else if (uiState.imageUrl != null) { - AsyncImage( + MoonAsyncImage( modifier = Modifier - .size(96.dp) .clip(Shapes.large) .clickable(onClick = { viewModel.openNft() }), - url = uiState.imageUrl!!, - size = 256 + image = uiState.imageUrl!!, + size = 96.dp, ) } else { TxDetailsIcon( @@ -109,6 +102,5 @@ fun TxDetailsComposable( } } - } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsIcon.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsIcon.kt index 15be369ea..ac0397ce5 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsIcon.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxDetailsIcon.kt @@ -25,7 +25,7 @@ fun TxDetailsIcon( icon = icon.url, subicon = icon.subicon, modifier = Modifier.size(96.dp), - size = 256 + size = 96.dp ) } } @@ -44,7 +44,7 @@ fun TxDetailsIcons( TokenImageBorder( modifier = Modifier.size(72.dp), icon = icon.url, - size = 128 + size = 72.dp ) } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamBadge.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamBadge.kt index 5e89d6851..b9b7741fd 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamBadge.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamBadge.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tonapps.wallet.localization.Localization import ui.theme.UIKit +import ui.utils.uppercased @Composable fun TxSpamBadge() { @@ -20,7 +21,7 @@ fun TxSpamBadge() { shape = RoundedCornerShape(8.dp) ) .padding(horizontal = 8.dp, vertical = 4.dp), - text = stringResource(Localization.spam).uppercase(), + text = stringResource(Localization.spam).uppercased(), style = UIKit.typography.label2, color = UIKit.colorScheme.text.primary ) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamButtons.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamButtons.kt index a62b9cbad..190c40cc9 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamButtons.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/details/ui/TxSpamButtons.kt @@ -6,24 +6,24 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.tonapps.wallet.localization.Localization -import ui.components.button.TKButton -import ui.theme.ButtonColorsOrange -import ui.theme.ButtonColorsSecondary -import ui.theme.ButtonSizeSmall +import ui.components.moon.MoonAccentButton +import ui.components.moon.ButtonColorsOrange +import ui.components.moon.ButtonColorsSecondary +import ui.components.moon.ButtonSizeSmall @Composable fun TxSpamButtons(onClick: (Boolean) -> Unit) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - TKButton( + MoonAccentButton( text = stringResource(Localization.report_spam), size = ButtonSizeSmall, buttonColors = ButtonColorsOrange, onClick = { onClick(true) }, ) - TKButton( + MoonAccentButton( text = stringResource(Localization.not_spam), size = ButtonSizeSmall, buttonColors = ButtonColorsSecondary, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsScreen.kt index 89562886f..b692697a4 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsScreen.kt @@ -6,9 +6,9 @@ import androidx.compose.runtime.Composable import com.tonapps.tonkeeper.koin.walletViewModel import com.tonapps.tonkeeper.ui.base.compose.ComposeWalletScreen import com.tonapps.tonkeeper.ui.screen.events.compose.history.ui.TxEventComposable -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity -class TxEventsScreen(wallet: WalletEntity) : ComposeWalletScreen(wallet) { +class TxEventsScreen(wallet: WalletEntity, private val canGoBack: Boolean) : ComposeWalletScreen(wallet) { override val viewModel: TxEventsViewModel by walletViewModel() @@ -17,10 +17,9 @@ class TxEventsScreen(wallet: WalletEntity) : ComposeWalletScreen(wallet) { } @Composable - override fun ScreenContent() = TxEventComposable(viewModel) + override fun ScreenContent() = TxEventComposable(viewModel, canGoBack = canGoBack, onBack = { finish() }) companion object { - - fun newInstance(wallet: WalletEntity) = TxEventsScreen(wallet) + fun newInstance(wallet: WalletEntity, canGoBack: Boolean = true) = TxEventsScreen(wallet, canGoBack) } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsViewModel.kt index b4bce5434..fb3a4b86e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxEventsViewModel.kt @@ -1,7 +1,6 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history import android.app.Application -import android.util.Log import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig @@ -9,10 +8,14 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.insertSeparators import androidx.paging.map +import com.tonapps.deposit.DepositFragment +import com.tonapps.deposit.DepositRoutes +import com.tonapps.deposit.screens.method.RampAsset +import com.tonapps.deposit.screens.qr.QrAssetFragment +import com.tonapps.blockchain.model.legacy.WalletCurrency import com.tonapps.extensions.MutableEffectFlow import com.tonapps.tonkeeper.helper.DateHelper -import com.tonapps.tonkeeper.manager.tx.TransactionManager -import com.tonapps.wallet.data.events.tx.model.TxEvent +import com.tonapps.wallet.data.tx.TransactionManager import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.tonkeeper.ui.screen.events.compose.TxScope.decryptComment import com.tonapps.tonkeeper.ui.screen.events.compose.details.TxDetailsScreen @@ -20,32 +23,29 @@ import com.tonapps.tonkeeper.ui.screen.events.compose.history.paging.TxPagingSou import com.tonapps.tonkeeper.ui.screen.events.compose.history.state.TxFilter import com.tonapps.tonkeeper.ui.screen.events.spam.SpamEventsScreen import com.tonapps.tonkeeper.ui.screen.nft.NftScreen -import com.tonapps.tonkeeper.ui.screen.onramp.main.OnRampScreen -import com.tonapps.tonkeeper.ui.screen.qr.QRScreen import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.collectibles.CollectiblesRepository +import com.tonapps.wallet.data.collectibles.entities.NftEntity import com.tonapps.wallet.data.events.EventsRepository +import com.tonapps.wallet.features.events.TxEventUiMapper import com.tonapps.wallet.data.events.tx.model.TxActionBody +import com.tonapps.wallet.data.events.tx.model.TxEvent import com.tonapps.wallet.data.passcode.PasscodeManager import com.tonapps.wallet.data.settings.SettingsRepository import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import okio.IOException import ui.components.events.EventItemClickPart import ui.components.events.UiEvent -import uikit.extensions.collectFlow import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -59,17 +59,12 @@ class TxEventsViewModel( private val collectiblesRepository: CollectiblesRepository, private val passcodeManager: PasscodeManager, private val transactionManager: TransactionManager, + private val txEventUiMapper: TxEventUiMapper, ) : BaseWalletVM(app) { - private companion object { - private val monthYearFormatter = SimpleDateFormat("MMMM_yyyy", Locale.US) - private val dayMonthFormatter = SimpleDateFormat("d_MMMM", Locale.US) - } - private val _uiCommandFlow = MutableEffectFlow() val uiCommandFlow = _uiCommandFlow.asSharedFlow() - private val uiMapper = TxUiMapper(context, wallet, eventsRepository, settingsRepository) private val locale = settingsRepository.getLocale() private val _selectedFilterIdFlow = MutableStateFlow(TxFilter.All.id) @@ -111,7 +106,7 @@ class TxEventsViewModel( if (decrypted.isNullOrEmpty()) { item } else { - uiMapper.changeText(item, decrypted) + txEventUiMapper.changeText(item, decrypted) } }.insertSeparators { before: UiEvent.Item?, after: UiEvent.Item? -> if (after == null) { @@ -129,7 +124,7 @@ class TxEventsViewModel( }.distinctUntilChanged().cachedIn(viewModelScope) init { - transactionManager.eventsFlow(wallet).collectFlow { + transactionManager.eventsFlow(wallet).collectFlow { event -> requestRefresh() selectFilterById() } @@ -201,13 +196,28 @@ class TxEventsViewModel( private fun onClick(id: String, part: EventItemClickPart) { val tx = TxPagingSource.get(id) ?: return - viewModelScope.launch { - if (part is EventItemClickPart.Product) { + if (part is EventItemClickPart.Product) { + viewModelScope.launch(Dispatchers.IO) { val product = tx.actions[part.index].product ?: return@launch + if (product.type == TxActionBody.Product.Type.Nft) { - openNft(product.id) + try { + val nftItem = collectiblesRepository.getNft( + accountId = wallet.accountId, + network = wallet.network, + address = product.id + ) ?: throw IOException() + + viewModelScope.launch { + openNft(nftItem) + } + } catch (ignored: Throwable) { + toast(Localization.unknown_error) + } } - } else if (part is EventItemClickPart.Encrypted) { + } + } else if (part is EventItemClickPart.Encrypted) { + viewModelScope.launch { decryptComment( wallet = wallet, tx = tx, @@ -217,23 +227,16 @@ class TxEventsViewModel( passcodeManager = passcodeManager, eventsRepository = eventsRepository ) - } else if (part is EventItemClickPart.Action) { + } + } else if (part is EventItemClickPart.Action) { + viewModelScope.launch { openDetails(tx, part.index) } } } - private suspend fun openNft(address: String) { - try { - val nftItem = collectiblesRepository.getNft( - accountId = wallet.accountId, - testnet = wallet.testnet, - address = address - ) ?: throw IOException() - openScreen(NftScreen.newInstance(wallet, nftItem)) - } catch (ignored: Throwable) { - toast(Localization.unknown_error) - } + private suspend fun openNft(nftItem: NftEntity) { + openScreen(NftScreen.newInstance(wallet, nftItem)) } private suspend fun openDetails(tx: TxEvent, actionIndex: Int) { @@ -242,13 +245,13 @@ class TxEventsViewModel( fun openBuyTon() { viewModelScope.launch { - openScreen(OnRampScreen.newInstance(context, wallet, "history")) + openScreen(DepositFragment.create(DepositRoutes.Buy(RampAsset.Currency(WalletCurrency.TON)))) } } fun openReceive() { viewModelScope.launch { - openScreen(QRScreen.newInstance(wallet)) + openScreen(QrAssetFragment.newInstance()) } } @@ -257,7 +260,12 @@ class TxEventsViewModel( accountRepository = accountRepository, eventsRepository = eventsRepository, settingsRepository = settingsRepository, - uiMapper = uiMapper + txEventUiMapper = txEventUiMapper ) -} \ No newline at end of file + private companion object { + private val monthYearFormatter = SimpleDateFormat("MMMM_yyyy", Locale.US) + private val dayMonthFormatter = SimpleDateFormat("d_MMMM", Locale.US) + } + +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxUiMapper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxUiMapper.kt deleted file mode 100644 index e3ac050e9..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/TxUiMapper.kt +++ /dev/null @@ -1,168 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.compose.history - -import android.content.Context -import com.tonapps.extensions.locale -import com.tonapps.icu.CurrencyFormatter -import com.tonapps.tonkeeper.core.history.iconRes -import com.tonapps.tonkeeper.core.history.nameRes -import com.tonapps.tonkeeper.extensions.externalDrawableUrl -import com.tonapps.tonkeeper.helper.DateHelper -import com.tonapps.tonkeeper.ui.screen.events.compose.history.state.getFilters -import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.events.ActionType -import com.tonapps.wallet.data.events.EventsRepository -import com.tonapps.wallet.data.events.tx.model.TxAction -import com.tonapps.wallet.data.events.tx.model.TxActionBody -import com.tonapps.wallet.data.events.tx.model.TxEvent -import com.tonapps.wallet.data.settings.SettingsRepository -import com.tonapps.wallet.data.settings.SpamTransactionState -import com.tonapps.wallet.localization.Localization -import kotlinx.collections.immutable.toImmutableList -import ui.components.events.UiEvent -import ui.uiPosition - -internal class TxUiMapper( - private val context: Context, - private val wallet: WalletEntity, - private val eventsRepository: EventsRepository, - private val settingsRepository: SettingsRepository, -) { - - private val moreButtonText = context.getString(Localization.more) - - fun changeText(item: UiEvent.Item, text: String): UiEvent.Item { - val index = item.actions.indexOfFirst { - it.text is UiEvent.Item.Action.Text.Encrypted - } - if (index == -1) { - return item - } - val action = item.actions[index].copy(text = UiEvent.Item.Action.Text.Plain(text, moreButtonText)) - val actions = item.actions.toMutableList() - actions[index] = action - return item.copy(actions = actions.toImmutableList()) - } - - private fun product(action: TxAction): UiEvent.Item.Action.Product? { - val product = action.product ?: return null - var subtitle = product.subtitle - var type = UiEvent.Item.Action.Product.Type.Default - if (action.hasUnverifiedNft) { - subtitle = context.getString(Localization.nft_unverified) - type = UiEvent.Item.Action.Product.Type.Wrong - } else if (action.hasVerifiedNft) { - type = UiEvent.Item.Action.Product.Type.Verified - } - return UiEvent.Companion.product( - title = product.title, - subtitle = subtitle.ifBlank { context.getString(Localization.unnamed_collection) }, - imageUrl = product.imageUrl, - type = type, - ) - } - - private fun text(hash: String, text: TxActionBody.Text): UiEvent.Item.Action.Text? { - if (text is TxActionBody.Text.Plain) { - return UiEvent.Companion.textPlain( - text = text.text, - moreButtonText = moreButtonText - ) - } else if (text is TxActionBody.Text.Encrypted) { - val decryptedComment = eventsRepository.getDecryptedComment(hash) - return if (decryptedComment.isNullOrBlank()) { - UiEvent.Companion.textEncrypted(context.getString(Localization.encrypted_comment)) - } else { - UiEvent.Companion.textPlain( - text = decryptedComment, - moreButtonText = moreButtonText - ) - } - } - return null - } - - private fun amount(action: TxAction): Pair { - var incomingFormatted = action.incomingFormatted - var outgoingFormatted = action.outgoingFormatted - - if (action.type == ActionType.Unknown) { - outgoingFormatted = action.body.value - } else if (action.type == ActionType.NftSend) { - outgoingFormatted = context.getString(Localization.nft) - } else if (action.type == ActionType.NftReceived || action.type == ActionType.NftPurchase) { - incomingFormatted = context.getString(Localization.nft) - } else { - if (!incomingFormatted.isNullOrBlank()) { - incomingFormatted = CurrencyFormatter.PREFIX_PLUS + incomingFormatted - } - if (!outgoingFormatted.isNullOrBlank()) { - outgoingFormatted = CurrencyFormatter.PREFIX_MINUS + outgoingFormatted - } - } - return incomingFormatted?.toString() to outgoingFormatted?.toString() - } - - private fun isSpam(event: TxEvent): Boolean { - val localSpamState = settingsRepository.getSpamStateTransaction(wallet.id, event.hash) - return if (localSpamState == SpamTransactionState.UNKNOWN) { - event.spam - } else localSpamState == SpamTransactionState.SPAM - } - - private fun state(event: TxEvent, action: TxAction): UiEvent.Item.Action.State { - return when { - event.inProgress -> UiEvent.Item.Action.State.Pending - action.status == TxAction.Status.Failed -> UiEvent.Item.Action.State.Failed - else -> UiEvent.Item.Action.State.Success - } - } - - fun event(event: TxEvent): UiEvent.Item { - val isSpam = isSpam(event) - val actions = event.actions.mapIndexed { index, action -> - val (incomingFormatted, outgoingFormatted) = amount(action) - - val badge = if (event.isTron) context.getString(Localization.trc20) else null - val state = state(event, action) - - val title = if (isSpam) { - context.getString(Localization.spam) - } else action.title ?: context.getString(action.type.nameRes) - - val iconUrl = if (isSpam || action.isFailed) { - context.externalDrawableUrl(UIKitIcon.ic_exclamationmark_circle_28) - } else context.externalDrawableUrl(action.type.iconRes) - - UiEvent.Item.Action( - state = state, - title = title, - subtitle = action.subtitle ?: context.getString(Localization.unknown), - incomingAmount = incomingFormatted, - outgoingAmount = outgoingFormatted, - date = DateHelper.formatTransactionTime(event.timestamp.toLong(), context.locale), - imageUrl = action.imageUrl, - iconUrl = iconUrl, - product = product(action), - badge = badge, - text = action.text?.let { - text(event.hash, it) - }, - warningText = if (action.isFailed) context.getString(Localization.failed) else null, - rightDescription = if (action.hasUnverifiedToken) context.getString(Localization.unverified_token) else null, - spam = isSpam, - position = uiPosition(index, event.actions.size) - ) - } - - return UiEvent.Item( - id = event.id, - timestamp = event.timestamp.toLong(), - actions = actions.toImmutableList(), - filterIds = event.getFilters().map { it.id }.toImmutableList(), - spam = actions.any { it.spam }, - progress = event.inProgress - ) - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxPagingSource.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxPagingSource.kt index bfd1bcc6f..d97a39bae 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxPagingSource.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxPagingSource.kt @@ -2,10 +2,10 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.paging import androidx.paging.PagingSource import androidx.paging.PagingState -import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxUiMapper +import com.tonapps.wallet.features.events.TxEventUiMapper import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.events.EventsRepository import com.tonapps.wallet.data.events.tx.TxFetchQuery import com.tonapps.wallet.data.events.tx.TxPage @@ -16,21 +16,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import ui.components.events.UiEvent import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean internal class TxPagingSource( private val wallet: WalletEntity, private val accountRepository: AccountRepository, private val eventsRepository: EventsRepository, private val settingsRepository: SettingsRepository, - private val uiMapper: TxUiMapper, + private val txEventUiMapper: TxEventUiMapper, ): PagingSource() { - companion object { - private val cache = ConcurrentHashMap() - - fun get(id: String) = cache[id] - } - private val tronParamsProvider = TxTronParamsProvider( wallet = wallet, accountRepository = accountRepository, @@ -54,7 +49,7 @@ internal class TxPagingSource( } else { val uiItems = data.events.map { cache[it.id] = it - uiMapper.event(it) + txEventUiMapper.toUiItem(it, wallet) } LoadResult.Page( @@ -92,18 +87,8 @@ internal class TxPagingSource( return processEvents(eventsRepository.fetch(query)) } - private suspend fun prevLoad(afterTimestamp: Timestamp, limit: Int): TxPage { - val query = query( - afterTimestamp = afterTimestamp, - limit = limit - ) - return processEvents(eventsRepository.fetch(query)) - } - private suspend fun initialLoad(limit: Int): TxPage { - val query = query( - limit = limit - ) + val query = query(limit = limit) return processEvents(eventsRepository.fetch(query)) } @@ -125,4 +110,10 @@ internal class TxPagingSource( } override fun getRefreshKey(state: PagingState) = null + + companion object { + private val cache = ConcurrentHashMap() + + fun get(id: String) = cache[id] + } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxTronParamsProvider.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxTronParamsProvider.kt index 09b284c26..f7710c662 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxTronParamsProvider.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/paging/TxTronParamsProvider.kt @@ -1,9 +1,9 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.paging -import android.util.Log +import com.tonapps.log.L import com.tonapps.tonkeeper.ui.screen.events.compose.history.state.TxTronParams import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.settings.SettingsRepository import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxFilter.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxFilter.kt index 61c9bf8f1..45f85cb76 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxFilter.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxFilter.kt @@ -1,12 +1,11 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.state import android.content.Context -import com.tonapps.wallet.api.entity.value.Blockchain import com.tonapps.wallet.data.events.ActionType import com.tonapps.wallet.data.events.tx.model.TxAction import com.tonapps.wallet.data.events.tx.model.TxEvent import com.tonapps.wallet.localization.Localization -import ui.components.filter.UiFilter +import ui.components.moon.MoonItem enum class TxFilter( val id: Int, @@ -20,7 +19,7 @@ enum class TxFilter( internal val supportedTxFilters = arrayOf(TxFilter.All, TxFilter.Send, TxFilter.Received, TxFilter.Spam) -fun TxFilter.toUi(context: Context) = UiFilter( +fun TxFilter.toUi(context: Context) = MoonItem( id = id, title = context.getString(titleResId) ) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxTronParams.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxTronParams.kt index d0462b1bd..0b39c0302 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxTronParams.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/state/TxTronParams.kt @@ -1,6 +1,6 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.state -import com.tonapps.wallet.api.entity.value.BlockchainAddress +import com.tonapps.blockchain.model.legacy.BlockchainAddress data class TxTronParams( val address: BlockchainAddress? = null, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsComposable.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsComposable.kt index 4c7ac6ed9..e65d9ef5f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsComposable.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsComposable.kt @@ -1,12 +1,19 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.ui +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.paging.compose.collectAsLazyPagingItems import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxComposableCommand @@ -14,46 +21,86 @@ import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxEventsViewModel import com.tonapps.tonkeeper.ui.screen.events.compose.history.paging.screenState import com.tonapps.tonkeeper.ui.screen.events.compose.history.state.TxScreenState import com.tonapps.tonkeeper.ui.screen.events.compose.history.ui.placeholder.TxEventsPlaceholder +import com.tonapps.uikit.icon.UIKitIcon +import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filterIsInstance import ui.animation.ContentCrossfade +import ui.components.base.UIKitScaffold +import ui.components.moon.MoonTopAppBarLarge +import ui.components.moon.MoonTopAppBarSimple @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TxEventComposable(viewModel: TxEventsViewModel) { - +fun TxEventComposable( + viewModel: TxEventsViewModel, + canGoBack: Boolean, + onBack: () -> Unit +) { val coroutineScope = rememberCoroutineScope { Dispatchers.IO } val items = viewModel.uiItemsFlow.collectAsLazyPagingItems(coroutineScope.coroutineContext) val selectedFilterId by viewModel.selectedFilterIdFlow.collectAsStateWithLifecycle() val hiddenBalances by viewModel.hiddenBalancesFlow.collectAsStateWithLifecycle() val screenState by remember { - derivedStateOf { - items.screenState(viewModel.selectedFilterId) - } + derivedStateOf { items.screenState(viewModel.selectedFilterId) } } LaunchedEffect(items) { - viewModel.uiCommandFlow - .filterIsInstance() + viewModel.uiCommandFlow.filterIsInstance() .collect { items.refresh() } } - ContentCrossfade( - targetState = screenState, - label = "TxEventsScreenState", - ) { screenState -> - when (screenState) { - TxScreenState.Empty -> TxHistoryEmpty(viewModel) - TxScreenState.Placeholder -> TxEventsPlaceholder() - TxScreenState.Error -> TxEventsError(items) - TxScreenState.List -> TxEventsContent( - items = items, - selectedFilterId = selectedFilterId, - hiddenBalances = hiddenBalances, - dispatch = viewModel::dispatch, - uiCommands = viewModel.uiCommandFlow - ) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState()) + + UIKitScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + if (canGoBack) { + MoonTopAppBarSimple( + modifier = Modifier.statusBarsPadding(), + title = stringResource(Localization.history), + navigationIconRes = UIKitIcon.ic_chevron_left_16, + onNavigationClick = onBack, + scrollBehavior = scrollBehavior, + ) + } else if (screenState == TxScreenState.List || screenState == TxScreenState.Placeholder) { + MoonTopAppBarLarge( + title = stringResource(Localization.history), + scrollBehavior = scrollBehavior, + ) + } + } + ) { innerPadding -> + ContentCrossfade( + targetState = screenState, + label = "TxEventsScreenState", + ) { state -> + when (state) { + TxScreenState.Empty -> TxHistoryEmpty( + viewModel = viewModel, + innerPadding = innerPadding, + ) + TxScreenState.Placeholder -> TxEventsPlaceholder( + innerPadding = innerPadding, + ) + TxScreenState.Error -> TxEventsError( + items = items, + innerPadding = innerPadding, + ) + TxScreenState.List -> TxEventsContent( + innerPadding = innerPadding, + canGoBack = canGoBack, + items = items, + selectedFilterId = selectedFilterId, + hiddenBalances = hiddenBalances, + dispatch = viewModel::dispatch, + uiCommands = viewModel.uiCommandFlow, + scrollBehavior = scrollBehavior, + ) + } } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsContent.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsContent.kt index 736e44b70..31cf9d69d 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsContent.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsContent.kt @@ -1,91 +1,80 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.ui import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxComposableCommand import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxEventsAction -import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.filterIsInstance -import ui.components.bar.top.LargeTopAppBar -import ui.components.base.UIKitScaffold import ui.components.events.UiEvent import ui.theme.Dimens @OptIn(ExperimentalMaterial3Api::class) @Composable fun TxEventsContent( + innerPadding: PaddingValues, + canGoBack: Boolean, items: LazyPagingItems, selectedFilterId: Int, hiddenBalances: Boolean, uiCommands: SharedFlow, + scrollBehavior: TopAppBarScrollBehavior, dispatch: (action: TxEventsAction) -> Unit ) { - val coroutineScope = rememberCoroutineScope() - val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) - UIKitScaffold( + Box( modifier = Modifier .fillMaxSize() - .nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - LargeTopAppBar( - title = stringResource(Localization.history), - scrollBehavior = scrollBehavior - ) - } - ) { innerPadding -> - Box( - modifier = Modifier.padding(innerPadding) - ) { - val listState = rememberLazyListState() + .padding(innerPadding) + ) { + val listState = rememberLazyListState() - LaunchedEffect(listState) { - uiCommands - .filterIsInstance() - .collect { - delay(32) - listState.scrollToItem(0) - with(scrollBehavior.state) { - heightOffset = 0f - contentOffset = 0f - } + LaunchedEffect(listState) { + uiCommands + .filterIsInstance() + .collect { + delay(32) + listState.scrollToItem(0) + with(scrollBehavior.state) { + heightOffset = 0f + contentOffset = 0f } - } + } + } - TxFiltersBar( - modifier = Modifier - .fillMaxWidth() - .height(Dimens.heightItem), - listState = listState, - selectedId = selectedFilterId, - dispatch = dispatch - ) + TxFiltersBar( + modifier = Modifier + .fillMaxWidth() + .height(Dimens.heightItem), + listState = listState, + selectedId = selectedFilterId, + dispatch = dispatch + ) - TxEventsItems( - items = items, - scope = coroutineScope, - listState = listState, - hiddenBalances = hiddenBalances, - dispatch = dispatch - ) - } + val bottomPadding = if (canGoBack) 0.dp else Dimens.heightBar + TxEventsItems( + modifier = Modifier.padding(top = Dimens.heightItem, bottom = bottomPadding), + items = items, + scope = coroutineScope, + listState = listState, + hiddenBalances = hiddenBalances, + dispatch = dispatch + ) } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsError.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsError.kt index ce4566d5d..45b3b3acf 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsError.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsError.kt @@ -1,11 +1,12 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.ui import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -14,25 +15,22 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import com.tonapps.wallet.localization.Localization -import ui.components.bar.top.LargeTopAppBar -import ui.components.button.TKButton +import ui.components.moon.MoonAccentButton import ui.components.events.UiEvent import ui.theme.Dimens import ui.theme.UIKit -@OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun TxEventsError(items: LazyPagingItems) { - Column( - modifier = Modifier.fillMaxWidth(), +internal fun TxEventsError( + items: LazyPagingItems, + innerPadding: PaddingValues, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, ) { - - LargeTopAppBar(stringResource(Localization.history)) - - Spacer( - modifier = Modifier.weight(1f) - ) - Column( modifier = Modifier .fillMaxWidth() @@ -43,18 +41,13 @@ internal fun TxEventsError(items: LazyPagingItems) { Text( text = stringResource(Localization.unknown_error), style = UIKit.typography.h2, - color = UIKit.colorScheme.text.secondary + color = UIKit.colorScheme.text.secondary, ) - TKButton( + MoonAccentButton( text = stringResource(Localization.retry), onClick = { items.refresh() }, ) } - - Spacer( - modifier = Modifier.weight(1f) - ) - } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsItems.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsItems.kt index 9d136ed54..c177cc9ad 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsItems.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxEventsItems.kt @@ -3,20 +3,14 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.Scaffold import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -29,23 +23,23 @@ import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxEventsAction import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import ui.components.PullToRefreshIndicator -import ui.components.TKFooterLoader -import ui.components.TKFooterRetry import ui.components.events.UiEvent +import ui.components.moon.MoonRefresh +import ui.components.moon.cell.MoonLoaderCell +import ui.components.moon.cell.MoonRetryCell import ui.theme.Dimens @OptIn(ExperimentalMaterial3Api::class) @Composable @NonRestartableComposable fun TxEventsItems( + modifier: Modifier, items: LazyPagingItems, scope: CoroutineScope, listState: LazyListState, hiddenBalances: Boolean, dispatch: (action: TxEventsAction) -> Unit ) { - val refreshState = rememberPullToRefreshState() val loadState = items.loadState @@ -58,14 +52,13 @@ fun TxEventsItems( } PullToRefreshBox( - modifier = Modifier - .fillMaxWidth() - .padding(top = Dimens.heightItem, bottom = Dimens.heightBar), + modifier = modifier + .fillMaxWidth(), isRefreshing = loadState.refresh is LoadState.Loading, onRefresh = { items.refresh() }, state = refreshState, indicator = { - PullToRefreshIndicator( + MoonRefresh( modifier = Modifier.align(Alignment.TopCenter), state = refreshState ) @@ -94,14 +87,14 @@ fun TxEventsItems( item(key = "offset", contentType = UiEvent.CONTENT_TYPE_OTHER) { when(loadState.append) { is LoadState.Error -> { - TKFooterRetry( + MoonRetryCell( message = stringResource(Localization.unknown_error), buttonText = stringResource(Localization.retry), onRetry = { items.retry() } ) } is LoadState.Loading -> { - TKFooterLoader() + MoonLoaderCell() } is LoadState.NotLoading -> { } } diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxFiltersBar.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxFiltersBar.kt index 0726f1b13..fa13e05ce 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxFiltersBar.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxFiltersBar.kt @@ -13,8 +13,8 @@ import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxEventsAction import com.tonapps.tonkeeper.ui.screen.events.compose.history.state.supportedTxFilters import com.tonapps.tonkeeper.ui.screen.events.compose.history.state.toUi import kotlinx.collections.immutable.toImmutableList -import ui.components.filter.FiltersBar -import ui.components.filter.UiFilter +import ui.components.moon.MoonChipBar +import ui.components.moon.MoonItem import ui.theme.modifiers.bottomDivider @OptIn(ExperimentalMaterial3Api::class) @@ -38,11 +38,11 @@ fun TxFiltersBar( } } - val onSelectClick = { filter: UiFilter -> + val onSelectClick = { filter: MoonItem -> dispatch(TxEventsAction.SelectFilter(filter.id)) } - FiltersBar( + MoonChipBar( modifier = modifier.bottomDivider(enabled = scrolledDown, insetStart = 0.dp), filters = filters, selectedId = selectedId, diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxHistoryEmpty.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxHistoryEmpty.kt index a5d2ff091..46b80bc3e 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxHistoryEmpty.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/TxHistoryEmpty.kt @@ -1,6 +1,7 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.ui import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -10,24 +11,28 @@ import androidx.compose.ui.res.stringResource import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxEventsAction import com.tonapps.tonkeeper.ui.screen.events.compose.history.TxEventsViewModel import com.tonapps.wallet.localization.Localization -import ui.components.TKEmptyPlaceholder +import ui.components.moon.cell.MoonEmptyCell import ui.theme.Dimens @Composable -fun TxHistoryEmpty(viewModel: TxEventsViewModel) { +fun TxHistoryEmpty( + viewModel: TxEventsViewModel, + innerPadding: PaddingValues, +) { Box( modifier = Modifier .fillMaxSize() + .padding(innerPadding) .padding(horizontal = Dimens.offsetLarge), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { - TKEmptyPlaceholder( + MoonEmptyCell( title = stringResource(Localization.empty_history_title), subtitle = stringResource(Localization.empty_history_subtitle), firstButtonText = stringResource(Localization.buy_toncoin), onFirstClick = { viewModel.dispatch(TxEventsAction.BuyTon) }, secondButtonText = stringResource(Localization.receive), - onSecondClick = { viewModel.dispatch(TxEventsAction.OpenQR) } + onSecondClick = { viewModel.dispatch(TxEventsAction.OpenQR) }, ) } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/placeholder/TxEventsPlaceholder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/placeholder/TxEventsPlaceholder.kt index de21fa543..c5235649a 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/placeholder/TxEventsPlaceholder.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/compose/history/ui/placeholder/TxEventsPlaceholder.kt @@ -2,36 +2,28 @@ package com.tonapps.tonkeeper.ui.screen.events.compose.history.ui.placeholder import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.tonapps.wallet.localization.Localization -import ui.components.bar.top.LargeTopAppBar import ui.theme.Dimens import ui.theme.modifiers.rememberShimmerPhase -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun TxEventsPlaceholder() { +fun TxEventsPlaceholder(innerPadding: PaddingValues) { Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(innerPadding), ) { - - LargeTopAppBar(stringResource(Localization.history)) - val shimmerPhase by rememberShimmerPhase() - val shimmerPhaseProvider = { shimmerPhase } - TxFiltersPlaceholder( - shimmerPhaseProvider = shimmerPhaseProvider - ) + TxFiltersPlaceholder(shimmerPhaseProvider = shimmerPhaseProvider) Column( modifier = Modifier @@ -39,21 +31,17 @@ fun TxEventsPlaceholder() { .padding(horizontal = Dimens.offsetMedium), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - TxEventItemPlaceholder( modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp), height = 32.dp, - shimmerPhaseProvider = shimmerPhaseProvider + shimmerPhaseProvider = shimmerPhaseProvider, ) for (i in 1..10) { - val height = remember { - (78..98).random().dp - } + val height = remember { (78..98).random().dp } TxEventItemPlaceholder(height = height, shimmerPhaseProvider = shimmerPhaseProvider) } } - } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsScreen.kt deleted file mode 100644 index 622008f45..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsScreen.kt +++ /dev/null @@ -1,226 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main - -import android.os.Bundle -import android.util.Log -import android.view.Gravity -import android.view.View -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.RecyclerView -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.facebook.shimmer.ShimmerFrameLayout -import com.tonapps.tonkeeper.core.AnalyticsHelper -import com.tonapps.tonkeeper.core.history.list.HistoryAdapter -import com.tonapps.tonkeeper.core.history.list.HistoryItemDecoration -import com.tonapps.tonkeeper.core.history.list.item.HistoryItem -import com.tonapps.tonkeeper.extensions.applyColors -import com.tonapps.tonkeeper.extensions.isLightTheme -import com.tonapps.tonkeeper.extensions.toast -import com.tonapps.tonkeeper.koin.walletViewModel -import com.tonapps.tonkeeper.ui.screen.browser.base.BrowserBaseScreen -import com.tonapps.tonkeeper.ui.screen.collectibles.manage.CollectiblesManageScreen -import com.tonapps.tonkeeper.ui.screen.events.main.filters.FilterItem -import com.tonapps.tonkeeper.ui.screen.events.main.filters.FiltersAdapter -import com.tonapps.tonkeeper.ui.screen.events.spam.SpamEventsScreen -import com.tonapps.tonkeeper.ui.screen.main.MainScreen -import com.tonapps.tonkeeper.ui.screen.onramp.main.OnRampScreen -import com.tonapps.tonkeeper.ui.screen.purchase.PurchaseScreen -import com.tonapps.tonkeeper.ui.screen.qr.QRScreen -import com.tonapps.tonkeeperx.R -import com.tonapps.uikit.color.backgroundPageColor -import com.tonapps.uikit.color.backgroundTransparentColor -import com.tonapps.uikit.list.LinearLayoutManager -import com.tonapps.uikit.list.ListPaginationListener -import com.tonapps.wallet.api.entity.TokenEntity -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.localization.Localization -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.take -import uikit.drawable.BarDrawable -import uikit.drawable.HeaderDrawable -import uikit.extensions.collectFlow -import uikit.extensions.dp -import uikit.widget.EmptyLayout -import uikit.widget.HeaderView - -class EventsScreen(wallet: WalletEntity) : MainScreen.Child(R.layout.fragment_main_events_list, wallet) { - - override val fragmentName: String = "EventsScreen" - - override val viewModel: EventsViewModel by walletViewModel() - - private val legacyAdapter = HistoryAdapter() - - private val filtersAdapter = FiltersAdapter { - if (it.type == FilterItem.TYPE_SPAM) { - navigation?.add(SpamEventsScreen.newInstance(screenContext.wallet)) - return@FiltersAdapter - } - viewModel.clickFilter(it) - scrollToFirst() - } - - private val paginationListener = object : ListPaginationListener() { - override fun onLoadMore() { - viewModel.loadMore() - } - } - - private lateinit var headerView: HeaderView - private lateinit var headerDrawable: HeaderDrawable - private lateinit var refreshView: SwipeRefreshLayout - private lateinit var listView: RecyclerView - private lateinit var filtersView: RecyclerView - private lateinit var emptyView: EmptyLayout - private lateinit var shimmerView: ShimmerFrameLayout - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - headerView = view.findViewById(R.id.header) - headerView.title = getString(Localization.history) - headerView.setTitleGravity(Gravity.START) - headerView.setSubtitle(Localization.updating) - headerView.hideIcon() - if (requireContext().isLightTheme) { - headerView.setColor(requireContext().backgroundPageColor) - } else { - headerView.setColor(requireContext().backgroundTransparentColor) - } - /*headerView.setRightButton(Localization.spam) { - navigation?.add(SpamEventsScreen.newInstance(screenContext.wallet)) - }*/ - - refreshView = view.findViewById(R.id.refresh) - refreshView.offsetTopAndBottom(0) - refreshView.setOnRefreshListener { - setLoading(true) - viewModel.refresh() - } - - headerDrawable = HeaderDrawable(requireContext()) - if (requireContext().isLightTheme) { - headerDrawable.setColor(requireContext().backgroundPageColor) - } else { - headerDrawable.setColor(requireContext().backgroundTransparentColor) - } - - filtersView = view.findViewById(R.id.filters) - filtersView.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false) - filtersView.adapter = filtersAdapter - filtersView.background = headerDrawable - filtersView.addItemDecoration(object : RecyclerView.ItemDecoration() { - override fun getItemOffsets(outRect: android.graphics.Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - outRect.right = 8.dp - } - }) - - listView = view.findViewById(R.id.list) - listView.addItemDecoration(HistoryItemDecoration()) - listView.adapter = legacyAdapter - listView.addOnScrollListener(paginationListener) - - emptyView = view.findViewById(R.id.empty) - emptyView.doOnButtonClick = { first -> - if (first) { - navigation?.add(OnRampScreen.newInstance(requireContext(), screenContext.wallet, "events")) - } else { - openQRCode() - } - } - - shimmerView = view.findViewById(R.id.shimmer) - shimmerView.applyColors() - - collectFlow(viewModel.uiFilterItemsFlow, filtersAdapter::submitList) - collectFlow(viewModel.uiStateFlow, ::applyState) - } - - private fun applyState(state: EventsUiState) { - shimmerView.visibility = View.GONE - refreshView.visibility = View.VISIBLE - filtersView.visibility = View.VISIBLE - - if (state.uiItems.isEmpty() && !state.loading) { - setEmptyState() - } else { - if (state.loading && !state.isFooterLoading) { - setLoading(true) - } - setListState(state.uiItems) { - if (!state.loading && !state.isFooterLoading) { - setLoading(false) - } else { - - } - } - } - } - - private fun scrollToFirst() { - listView.scrollToPosition(0) - collectFlow(viewModel.uiStateFlow.take(1)) { - listView.scrollToPosition(0) - } - } - - private fun setLoading(loading: Boolean) { - if (loading && refreshView.isRefreshing) { - return - } else if (!loading && refreshView.isRefreshing) { - refreshView.isRefreshing = false - } - if (loading) { - headerView.setSubtitle(Localization.updating) - } else { - headerView.setSubtitle(null) - } - } - - override fun scrollUp() { - super.scrollUp() - listView.scrollToPosition(0) - viewModel.refresh() - } - - override fun onResume() { - super.onResume() - viewModel.refresh() - } - - private fun openQRCode() { - navigation?.add(QRScreen.newInstance(screenContext.wallet, TokenEntity.TON)) - } - - private fun setEmptyState() { - setLoading(false) - headerView.setSubtitle(null) - emptyView.visibility = View.VISIBLE - refreshView.visibility = View.GONE - } - - private fun setListState(uiItems: List, commitCallback: Runnable) { - legacyAdapter.submitList(uiItems, commitCallback) - emptyView.visibility = View.GONE - refreshView.visibility = View.VISIBLE - } - - override fun getRecyclerView(): RecyclerView? { - if (this::listView.isInitialized) { - return listView - } - return null - } - - override fun getTopBarDrawable(): BarDrawable? { - if (this::headerDrawable.isInitialized) { - return headerDrawable - } - return null - } - - companion object { - - fun newInstance(wallet: WalletEntity): EventsScreen { - return EventsScreen(wallet) - } - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsUiState.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsUiState.kt deleted file mode 100644 index 57de30d7a..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsUiState.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main - -import com.tonapps.tonkeeper.core.history.list.item.HistoryItem - -data class EventsUiState( - val uiItems: List, - val loading: Boolean, -) { - - val isFooterLoading: Boolean = uiItems.lastOrNull()?.let { it is HistoryItem.Loader } ?: false - - var isFooterFailed: Boolean = uiItems.lastOrNull()?.let { it is HistoryItem.Failed } ?: false -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsViewModel.kt deleted file mode 100644 index 1f916efd7..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/EventsViewModel.kt +++ /dev/null @@ -1,456 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main - -import android.app.Application -import androidx.lifecycle.viewModelScope -import com.tonapps.extensions.MutableEffectFlow -import com.tonapps.extensions.mapList -import com.tonapps.tonkeeper.api.AccountEventWrap -import com.tonapps.tonkeeper.core.history.ActionOptions -import com.tonapps.tonkeeper.core.history.ActionOutStatus -import com.tonapps.tonkeeper.core.history.HistoryHelper -import com.tonapps.tonkeeper.core.history.list.item.HistoryItem -import com.tonapps.tonkeeper.extensions.isSafeModeEnabled -import com.tonapps.tonkeeper.extensions.notificationsFlow -import com.tonapps.tonkeeper.extensions.refreshNotifications -import com.tonapps.tonkeeper.helper.CacheHelper -import com.tonapps.tonkeeper.manager.tx.TransactionManager -import com.tonapps.tonkeeper.ui.base.BaseWalletVM -import com.tonapps.tonkeeper.ui.screen.events.main.filters.FilterItem -import com.tonapps.wallet.api.API -import com.tonapps.wallet.api.entity.value.Timestamp -import com.tonapps.wallet.api.tron.entity.TronEventEntity -import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.dapps.DAppsRepository -import com.tonapps.wallet.data.events.EventsRepository -import com.tonapps.wallet.data.settings.SettingsRepository -import io.tonapi.models.AccountEvent -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.util.concurrent.atomic.AtomicBoolean -import kotlin.math.min -import kotlin.time.Duration.Companion.seconds - -// TODO: Refactor this class -class EventsViewModel( - app: Application, - private val wallet: WalletEntity, - private val eventsRepository: EventsRepository, - private val historyHelper: HistoryHelper, - private val settingsRepository: SettingsRepository, - private val transactionManager: TransactionManager, - private val dAppsRepository: DAppsRepository, - private val accountRepository: AccountRepository, - private val api: API, - private val cacheHelper: CacheHelper, -) : BaseWalletVM(app) { - - private var autoRefreshJob: Job? = null - - private val _triggerFlow = MutableEffectFlow() - private val _loadingTriggerFlow = MutableEffectFlow() - - private val _selectedFilter = MutableStateFlow(FilterItem.All(true)) - private val selectedFilter = _selectedFilter.asStateFlow() - - private val dAppsNotificationsFlow = - dAppsRepository.notificationsFlow(wallet, viewModelScope).map { - it.notifications - } - - val uiFilterItemsFlow: Flow> = - combine(selectedFilter, dAppsNotificationsFlow) { selected, notifications -> - val uiFilterItems = mutableListOf() - uiFilterItems.add(FilterItem.All(selected.type == FilterItem.TYPE_ALL)) - uiFilterItems.add(FilterItem.Send(selected.type == FilterItem.TYPE_SEND)) - uiFilterItems.add(FilterItem.Receive(selected.type == FilterItem.TYPE_RECEIVE)) - if (notifications.isNotEmpty()) { - uiFilterItems.add(FilterItem.Dapps(selected.type == FilterItem.TYPE_DAPPS)) - } - uiFilterItems.add(FilterItem.Spam()) - uiFilterItems.toList() - } - - private val isLoading: AtomicBoolean = AtomicBoolean(false) - private val isError: AtomicBoolean = AtomicBoolean(false) - - private var nextFrom: Long? = null - - private val isFirstLoad = AtomicBoolean(true) - - private val _eventsFlow = MutableStateFlow?>(null) - - private val eventsFlow = _eventsFlow.asStateFlow().filterNotNull() - - private val _tronEventsFlow = MutableStateFlow>(emptyList()) - - private val tronEventsFlow = _tronEventsFlow.asStateFlow().filterNotNull() - - private val historyItemsFlow = combine( - eventsFlow.map { list -> list.map { it.event } }, - tronEventsFlow, - _triggerFlow, - dAppsNotificationsFlow.mapList { HistoryItem.App(context, wallet, it) }, - ) { events, tronEvents, _, dAppNotifications -> - val historyEvents = mapping(events) - val lastEventsTimestamp = historyEvents.lastOrNull()?.timestampForSort ?: 0L - val tronHistoryEvents = if (nextFrom != null) { - tronMapping(tronEvents).filter { it.timestampForSort > lastEventsTimestamp } - } else { - tronMapping(tronEvents) - } - - val hasFilter = _selectedFilter.value != null - - if (hasFilter) { - (historyEvents + tronHistoryEvents + dAppNotifications).sortedBy { it.timestampForSort } - } else { - val lastTronTimestamp = tronHistoryEvents.lastOrNull()?.timestampForSort ?: 0L - val lastTimestamp = min(lastEventsTimestamp, lastTronTimestamp) - (historyEvents + tronHistoryEvents + dAppNotifications.filter { it.timestamp > lastTimestamp }).sortedBy { it.timestampForSort } - } - } - - private val uiItemsFlow = combine( - historyItemsFlow, selectedFilter, _loadingTriggerFlow - ) { historyItems, filter, _ -> - val actionOutStatus = resolveActionOutStatus(resolveFilter(filter)) - val uiItems = historyHelper.groupByDate(historyItems.filter { - when (actionOutStatus) { - ActionOutStatus.dApps -> it is HistoryItem.App - ActionOutStatus.App -> it is HistoryItem.App && it.url == (filter as? FilterItem.App)?.url - ActionOutStatus.Send -> it is HistoryItem.Event && (it.actionOutStatus == ActionOutStatus.Send || it.actionOutStatus == ActionOutStatus.Any) - ActionOutStatus.Received -> it is HistoryItem.Event && (it.actionOutStatus == ActionOutStatus.Received || it.actionOutStatus == ActionOutStatus.Any) - else -> true - } - }) - val list = if (isError.get()) { - historyHelper.withFailedItem(uiItems) - } else if (isLoading.get()) { - historyHelper.withLoadingItem(uiItems) - } else { - historyHelper.removeServiceItems(uiItems) - } - if (actionOutStatus == ActionOutStatus.Any || actionOutStatus == null) { - cacheHelper.setEventsCached(wallet, list) - } - list - }.flowOn(Dispatchers.IO) - - val installId: String - get() = settingsRepository.installId - - val uiStateFlow: Flow = flow { - val cached = cacheHelper.getEventsCached(wallet) - if (cached.isNotEmpty()) { - emit( - EventsUiState( - uiItems = cached, loading = true - ) - ) - } - - emitAll(uiItemsFlow.map { - EventsUiState( - uiItems = it, - loading = isLoading.get(), - ) - }) - } - - private val tronEnabled: Boolean - get() = settingsRepository.getTronUsdtEnabled(wallet.id) - - init { - with(settingsRepository) { - tokenPrefsChangedFlow.drop(1).collectFlow { initialLoad() } - walletPrefsChangedFlow.drop(1).collectFlow { initialLoad() } - safeModeStateFlow.drop(1).collectFlow { initialLoad() } - hiddenBalancesFlow.drop(1).collectFlow { updateState() } - } - transactionManager.eventsFlow(wallet).drop(1).collectFlow { initialLoad() } - transactionManager.tronUpdatedFlow.drop(1).collectFlow { refreshTron() } - eventsRepository.decryptedCommentFlow.collectFlow { updateState() } - autoRefreshJob = viewModelScope.launch { - while (isActive) { - checkAutoRefresh() - delay(35.seconds) - } - } - - viewModelScope.launch { - dAppsRepository.refreshNotifications(wallet, accountRepository) - } - _triggerFlow.tryEmit(Unit) - } - - private fun setError() { - isError.set(true) - if (_eventsFlow.value == null) { - _eventsFlow.value = emptyArray() - setLoading(loading = false, trigger = false) - } else { - setLoading(loading = false, trigger = true) - } - } - - private fun setLoading(loading: Boolean, trigger: Boolean) { - isLoading.set(loading) - if (trigger) { - _loadingTriggerFlow.tryEmit(Unit) - } - } - - private fun resolveFilter(item: FilterItem?): Int { - return if (item == null) { - TX_FILTER_NONE - } else { - when (item.id) { - FilterItem.SEND_ID -> TX_FILTER_SENT - FilterItem.RECEIVE_ID -> TX_FILTER_RECEIVED - FilterItem.DAPPS_ID -> TX_FILTER_DAPPS - else -> TX_FILTER_APP - } - } - } - - private fun resolveActionOutStatus(filter: Int): ActionOutStatus? { - return when (filter) { - // TX_FILTER_APP -> ActionOutStatus.App - TX_FILTER_SENT -> ActionOutStatus.Send - TX_FILTER_RECEIVED -> ActionOutStatus.Received - TX_FILTER_DAPPS -> ActionOutStatus.dApps - else -> return null - } - } - - fun clickFilter(filter: FilterItem) { - if (_selectedFilter.value.id == filter.id) { - _selectedFilter.value = FilterItem.All(true) - } else { - _selectedFilter.value = filter - } - } - - private fun checkAutoRefresh() { - if (hasPendingEvents()) { - refresh() - } - } - - fun refresh() { - viewModelScope.launch { - initialLoad() - } - } - - private suspend fun refreshTron() = withContext(Dispatchers.IO) { - val tronAddress = if (wallet.hasPrivateKey && !wallet.testnet) { - accountRepository.getTronAddress(wallet.id) - } else null - val tonProofToken = accountRepository.requestTonProofToken(wallet) - - if (tronAddress == null || tonProofToken == null) { - return@withContext - } - - val tronEvents = - eventsRepository.loadTronEvents(tronAddress, tonProofToken) ?: return@withContext - - _tronEventsFlow.update { items -> - (items + tronEvents).distinctBy { it.transactionHash }.sortedBy { it.timestamp } - .reversed() - } - } - - private suspend fun initialLoad() = - withContext(Dispatchers.IO) { - if (isLoading.get()) { - return@withContext - } - - setLoading(loading = true, trigger = true) - - val tronAddress = if (wallet.hasPrivateKey && !wallet.testnet && tronEnabled) { - accountRepository.getTronAddress(wallet.id) - } else null - val tonProofToken = accountRepository.requestTonProofToken(wallet) ?: "" - - if (isFirstLoad.get()) { - val cached = cache().toTypedArray() - _tronEventsFlow.value = emptyList() - if (cached.isNotEmpty()) { - _eventsFlow.value = cached - } - - isFirstLoad.set(false) - } - - try { - val events = loadDefault(beforeLt = null, limit = 30).toTypedArray() - val tronEvents = - tronAddress?.let { eventsRepository.loadTronEvents(tronAddress, tonProofToken) } - ?: emptyList() - isError.set(false) - setLoading(loading = false, trigger = false) - - _eventsFlow.value = events - _tronEventsFlow.value = tronEvents - } catch (e: Throwable) { - setError() - } - } - - fun loadMore() { - if (isLoading.get() || _selectedFilter.value is FilterItem.App || _selectedFilter.value is FilterItem.Dapps) { - return - } - - setLoading(loading = true, trigger = true) - viewModelScope.launch(Dispatchers.IO) { - val currentEvents = (_eventsFlow.value?.toMutableList() ?: mutableListOf()) - try { - val events = if (nextFrom != null) { - val beforeLtEvents = loadDefault( - beforeLt = nextFrom, limit = 30 - ) - (currentEvents + beforeLtEvents).distinctBy { it.eventId } - .sortedBy { it.timestamp }.reversed() - } else null - - loadMoreTron() - - if (events != null) { - _eventsFlow.value = events.toTypedArray() - } - - isError.set(false) - setLoading(loading = false, trigger = false) - } catch (e: Throwable) { - setError() - } - } - } - - private suspend fun loadMoreTron() { - val tronAddress = if (wallet.hasPrivateKey && !wallet.testnet && tronEnabled) { - accountRepository.getTronAddress(wallet.id) - } else null - - if (tronAddress.isNullOrEmpty()) { - return - } - - val tonProofToken = accountRepository.requestTonProofToken(wallet) ?: "" - try { - val tronLastLt = getTronLastLt() - if (tronLastLt != null) { - val tronEvents = eventsRepository.loadTronEvents( - tronAddress, tonProofToken, maxTimestamp = tronLastLt.value - ) ?: throw IllegalStateException("Failed to load tron events") - - _tronEventsFlow.update { currentEvents -> - (currentEvents + tronEvents).distinctBy { it.transactionHash } - .sortedBy { it.timestamp }.reversed() - } - } - } catch (_: Throwable) { - } - } - - private suspend fun mapping(events: List): List { - val eventItems = historyHelper.mapping( - wallet = wallet, events = events.map { it.copy() }, options = ActionOptions( - spamFilter = ActionOptions.SpamFilter.NOT_SPAM, - safeMode = settingsRepository.isSafeModeEnabled(api), - hiddenBalances = settingsRepository.hiddenBalances, - tronEnabled = settingsRepository.getTronUsdtEnabled(wallet.id), - ) - ) - return eventItems - } - - private suspend fun tronMapping(events: List): List { - val tronAddress = accountRepository.getTronAddress(wallet.id) ?: "" - val eventItems = historyHelper.tronMapping( - wallet = wallet, - events = events.map { it.copy() }, - tronAddress = tronAddress, - options = ActionOptions( - spamFilter = ActionOptions.SpamFilter.NOT_SPAM, - safeMode = settingsRepository.isSafeModeEnabled(api), - hiddenBalances = settingsRepository.hiddenBalances - ) - ) - return eventItems - } - - private suspend fun updateState() { - _triggerFlow.emit(Unit) - } - - private fun getTronLastLt(): Timestamp? { - val lt = _tronEventsFlow.value.lastOrNull { !it.inProgress }?.timestamp ?: return null - if (Timestamp.zero >= lt) { - return null - } - return lt - } - - private fun hasPendingEvents(): Boolean { - return _eventsFlow.value?.firstOrNull { it.inProgress } != null - } - - private suspend fun loadDefault(beforeLt: Long?, limit: Int): List { - val response = eventsRepository.getRemote( - accountId = wallet.accountId, - testnet = wallet.testnet, - beforeLt = beforeLt, - limit = limit, - ) ?: throw IllegalStateException("Failed to load events") - - nextFrom = if (response.events.size == limit) { - response.nextFrom - } else { - null - } - - return response.events.map(::AccountEventWrap) - } - - private suspend fun cache(): List { - val list = eventsRepository.getLocal( - accountId = wallet.accountId, testnet = wallet.testnet - )?.events?.map { AccountEventWrap.cached(it) } - return list ?: emptyList() - } - - override fun onCleared() { - super.onCleared() - autoRefreshJob?.cancel() - autoRefreshJob = null - } - - private companion object { - private const val TX_FILTER_NONE = 0 - private const val TX_FILTER_SENT = 1 - private const val TX_FILTER_RECEIVED = 2 - private const val TX_FILTER_APP = 3 - private const val TX_FILTER_DAPPS = 4 - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/FilterItem.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/FilterItem.kt deleted file mode 100644 index a7c581e92..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/FilterItem.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.filters - -import android.net.Uri -import android.os.Parcelable -import androidx.core.net.toUri -import com.tonapps.uikit.list.BaseListItem -import com.tonapps.wallet.data.dapps.entities.AppEntity -import com.tonapps.wallet.localization.Localization -import kotlinx.parcelize.Parcelize - -sealed class FilterItem( - type: Int, - open val selected: Boolean, - open val id: String, - val localization: Int -): BaseListItem(type) { - - companion object { - - const val TYPE_ALL = 0 - const val TYPE_SEND = 1 - const val TYPE_RECEIVE = 2 - const val TYPE_APP = 3 - const val TYPE_SPAM = 4 - const val TYPE_DAPPS = 5 - - const val ALL_ID = "all" - const val SEND_ID = "send" - const val RECEIVE_ID = "receive" - const val SPAM_ID = "spam" - const val DAPPS_ID = "dapps" - } - - data class All(override val selected: Boolean) : FilterItem(TYPE_ALL, selected, ALL_ID, Localization.all) - - data class Send(override val selected: Boolean) : FilterItem(TYPE_SEND, selected, SEND_ID, Localization.sent) - - data class Receive(override val selected: Boolean) : FilterItem(TYPE_RECEIVE, selected, RECEIVE_ID, Localization.received) - - data class Spam(override val selected: Boolean = false) : FilterItem(TYPE_SPAM, false, SPAM_ID, Localization.spam) - - data class Dapps(override val selected: Boolean) : FilterItem(TYPE_DAPPS, selected, DAPPS_ID, Localization.dApps) - - @Parcelize - data class App( - override val selected: Boolean, - val app: AppEntity, - ): FilterItem(TYPE_APP, selected, app.id, 0), Parcelable { - - val name: String - get() = app.name - - val iconUrl: Uri - get() = app.iconUrl.toUri() - - val url: Uri - get() = app.url - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/FiltersAdapter.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/FiltersAdapter.kt deleted file mode 100644 index 92023cd37..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/FiltersAdapter.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.filters - -import android.view.ViewGroup -import com.tonapps.tonkeeper.ui.screen.events.main.filters.holder.AppHolder -import com.tonapps.tonkeeper.ui.screen.events.main.filters.holder.FilterHolder -import com.tonapps.uikit.list.BaseListAdapter -import com.tonapps.uikit.list.BaseListHolder -import com.tonapps.uikit.list.BaseListItem - -class FiltersAdapter(private val onClick: (item: FilterItem) -> Unit): BaseListAdapter() { - - override fun createHolder(parent: ViewGroup, viewType: Int): BaseListHolder { - return when(viewType) { - FilterItem.TYPE_ALL, FilterItem.TYPE_SEND, FilterItem.TYPE_RECEIVE, FilterItem.TYPE_SPAM, FilterItem.TYPE_DAPPS -> FilterHolder(parent, onClick) - FilterItem.TYPE_APP -> AppHolder(parent, onClick) - else -> throw IllegalArgumentException("Unknown viewType: $viewType") - } - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/AppHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/AppHolder.kt deleted file mode 100644 index 05d774383..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/AppHolder.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.filters.holder - -import android.view.View -import android.view.ViewGroup -import androidx.core.view.updatePadding -import com.tonapps.tonkeeper.ui.screen.events.main.filters.FilterItem -import com.tonapps.tonkeeperx.R -import com.tonapps.uikit.color.constantWhiteColor -import uikit.extensions.dp -import uikit.extensions.drawable -import uikit.extensions.withAlpha -import uikit.widget.AsyncImageView - -class AppHolder( - parent: ViewGroup, - private val onClick: (item: FilterItem) -> Unit -): Holder(parent) { - - private val iconView = findViewById(R.id.icon) - - init { - itemView.updatePadding( - left = 6.dp, - right = 14.dp - ) - iconView.visibility = View.VISIBLE - iconView.setPlaceholder(context.drawable(uikit.R.drawable.bg_oval).apply { - setTint(context.constantWhiteColor.withAlpha(.2f)) - }) - } - - override fun onBind(item: FilterItem.App) { - itemView.setOnClickListener { onClick(item) } - titleView.text = item.name - iconView.setImageURI(item.iconUrl, null) - updateSelected(item) - } - - fun updateSelected(item: FilterItem) { - setSelected(item.selected) - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/FilterHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/FilterHolder.kt deleted file mode 100644 index 5ab8495a5..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/FilterHolder.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.filters.holder - -import android.view.ViewGroup -import com.tonapps.tonkeeper.ui.screen.events.main.filters.FilterItem -import com.tonapps.wallet.localization.Localization -import uikit.extensions.dp -import uikit.extensions.setPaddingHorizontal - -class FilterHolder( - parent: ViewGroup, - private val onClick: (item: FilterItem) -> Unit -): Holder(parent) { - - init { - itemView.setPaddingHorizontal(14.dp) - } - - override fun onBind(item: FilterItem) { - itemView.setOnClickListener { onClick(item) } - titleView.setText(item.localization) - updateSelected(item) - } - - fun updateSelected(item: FilterItem) { - setSelected(item.selected) - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/Holder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/Holder.kt deleted file mode 100644 index 2e536a354..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/filters/holder/Holder.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.filters.holder - -import android.view.ViewGroup -import androidx.appcompat.widget.AppCompatTextView -import com.tonapps.tonkeeper.ui.screen.events.main.filters.FilterItem -import com.tonapps.tonkeeperx.R -import com.tonapps.uikit.color.buttonPrimaryBackgroundColor -import com.tonapps.uikit.color.buttonPrimaryForegroundColor -import com.tonapps.uikit.color.buttonSecondaryBackgroundColor -import com.tonapps.uikit.color.buttonSecondaryForegroundColor -import com.tonapps.uikit.color.buttonTertiaryBackgroundColor -import com.tonapps.uikit.color.stateList -import com.tonapps.uikit.list.BaseListHolder - -abstract class Holder( - parent: ViewGroup, -): BaseListHolder(parent, R.layout.view_filter_chip) { - - val titleView = itemView.findViewById(R.id.title) - - fun setSelected(selected: Boolean) { - val textColor = if (selected) context.buttonPrimaryForegroundColor else context.buttonSecondaryForegroundColor - val color = if (selected) context.buttonTertiaryBackgroundColor else context.buttonSecondaryBackgroundColor - itemView.backgroundTintList = color.stateList - titleView.setTextColor(textColor) - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/Adapter.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/Adapter.kt deleted file mode 100644 index dc50c5977..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/Adapter.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.list - -import android.view.ViewGroup -import com.tonapps.tonkeeper.ui.screen.events.main.list.holder.ActionHolder -import com.tonapps.tonkeeper.ui.screen.events.main.list.holder.DateHolder -import com.tonapps.tonkeeper.ui.screen.events.main.list.holder.SpaceHolder -import com.tonapps.uikit.list.BaseListAdapter -import com.tonapps.uikit.list.BaseListHolder -import com.tonapps.uikit.list.BaseListItem - -class Adapter: BaseListAdapter() { - - override fun createHolder(parent: ViewGroup, viewType: Int): BaseListHolder { - return when(viewType) { - Item.TYPE_DATE -> DateHolder(parent) - Item.TYPE_ACTION -> ActionHolder(parent) - Item.TYPE_SPACE -> SpaceHolder(parent) - else -> throw IllegalArgumentException("Unknown viewType: $viewType") - } - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/Item.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/Item.kt deleted file mode 100644 index b61170340..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/Item.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.list - -import android.net.Uri -import com.tonapps.extensions.uri -import com.tonapps.tonkeeperx.R -import com.tonapps.uikit.color.UIKitColor -import com.tonapps.uikit.icon.UIKitIcon -import com.tonapps.uikit.list.BaseListItem -import com.tonapps.uikit.list.ListCell -import com.tonapps.wallet.api.entity.AccountEntity -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.collectibles.entities.NftEntity -import com.tonapps.wallet.localization.Localization - -sealed class Item(type: Int): BaseListItem(type) { - - companion object { - const val TYPE_DATE = 0 - const val TYPE_ACTION = 1 - const val TYPE_SPACE = 2 - } - - data class Date(val date: String): Item(TYPE_DATE) - - open class Action( - val position: ListCell.Position, - val iconUri: Uri, - val titleRes: Int, - val subtitle: String, - val comment: String? = null, - val loading: Boolean = false, - val value: CharSequence? = null, - val value2: CharSequence? = null, - val valueColorRef: Int = UIKitColor.textPrimaryColor, - val nft: NftEntity? = null, - val wallet: WalletEntity, - ): Item(TYPE_ACTION) - - class SendAction( - position: ListCell.Position, - account: AccountEntity, - comment: String?, - loading: Boolean, - value: CharSequence, - nft: NftEntity? = null, - wallet: WalletEntity - ): Action( - position = position, - iconUri = account.iconUri ?: UIKitIcon.ic_tray_arrow_up_28.uri(), - titleRes = Localization.sent, - subtitle = account.accountName, - comment = comment, - loading = loading, - value = value, - nft = nft, - wallet = wallet - ) - - class ReceiveAction( - position: ListCell.Position, - account: AccountEntity, - comment: String?, - loading: Boolean, - value: CharSequence, - nft: NftEntity? = null, - wallet: WalletEntity - ): Action( - position = position, - iconUri = account.iconUri ?: R.drawable.ic_tray_arrow_down_28.uri(), - titleRes = Localization.received, - subtitle = account.accountName, - comment = comment, - loading = loading, - value = value, - valueColorRef = UIKitColor.accentGreenColor, - nft = nft, - wallet = wallet - ) - - class Swap( - position: ListCell.Position, - loading: Boolean, - accountName: String, - value: String, - value2: String, - wallet: WalletEntity - ): Action( - position = position, - iconUri = R.drawable.ic_swap_horizontal_alternative_28.uri(), - titleRes = Localization.swap, - subtitle = accountName, - loading = loading, - value = value, - valueColorRef = UIKitColor.accentGreenColor, - value2 = value2, - wallet = wallet - ) - - class UnknownAction( - position: ListCell.Position, - wallet: WalletEntity - ): Action( - position = position, - iconUri = UIKitIcon.ic_gear_28.uri(), - titleRes = Localization.unknown, - subtitle = "", - comment = null, - wallet = wallet - ) - - data object Space: Item(TYPE_SPACE) -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/ActionHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/ActionHolder.kt deleted file mode 100644 index 35cb3da61..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/ActionHolder.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.list.holder - -import android.view.View -import android.view.ViewGroup -import androidx.appcompat.widget.AppCompatTextView -import com.tonapps.tonkeeper.ui.screen.events.main.list.Item -import com.tonapps.tonkeeper.ui.screen.nft.NftScreen -import com.tonapps.tonkeeperx.R -import com.tonapps.uikit.color.resolveColor -import com.tonapps.wallet.data.account.entities.WalletEntity -import com.tonapps.wallet.data.collectibles.entities.NftEntity -import uikit.extensions.drawable -import uikit.navigation.Navigation -import uikit.widget.AsyncImageView -import uikit.widget.LoaderView - -class ActionHolder(parent: ViewGroup): Holder(parent, R.layout.view_history_action) { - - private val loaderView = findViewById(R.id.loader) - private val iconView = findViewById(R.id.icon) - private val titleView = findViewById(R.id.title) - private val subtitleView = findViewById(R.id.subtitle) - private val amountView = findViewById(R.id.amount) - private val amount2View = findViewById(R.id.amount2) - - private val bodyView = findViewById(R.id.body) - - private val commentView = findViewById(R.id.comment) - private val nftView = findViewById(R.id.nft) - private val nftIconView = findViewById(R.id.nft_icon) - private val nftNameView = findViewById(R.id.nft_name) - private val nftCollectionView = findViewById(R.id.nft_collection) - - init { - amount2View.visibility = View.GONE - } - - override fun onBind(item: Item.Action) { - itemView.background = item.position.drawable(context) - iconView.setImageURI(item.iconUri, null) - loaderView.visibility = if (item.loading) View.VISIBLE else View.GONE - - titleView.setText(item.titleRes) - subtitleView.text = item.subtitle - setComment(item.comment) - if (item.nft == null && item.comment.isNullOrBlank()) { - bodyView.visibility = View.GONE - } else { - bodyView.visibility = View.VISIBLE - setValue(item.value, item.valueColorRef) - setNft(item.wallet, item.nft) - } - } - - private fun setComment(comment: String?) { - if (comment.isNullOrBlank()) { - commentView.visibility = View.GONE - } else { - commentView.visibility = View.VISIBLE - commentView.text = comment - } - } - - private fun setValue(value: CharSequence?, colorRef: Int) { - if (value.isNullOrBlank()) { - amountView.visibility = View.GONE - } else { - amountView.visibility = View.VISIBLE - amountView.text = value - amountView.setTextColor(context.resolveColor(colorRef)) - } - } - - private fun setNft(wallet: WalletEntity, nft: NftEntity?) { - if (nft == null) { - nftView.visibility = View.GONE - } else { - nftView.visibility = View.VISIBLE - nftView.setOnClickListener { - Navigation.from(context)?.add(NftScreen.newInstance(wallet, nft)) - } - nftIconView.setImageURI(nft.thumbUri, null) - nftNameView.text = nft.name - nftCollectionView.text = nft.collectionName - } - } -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/DateHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/DateHolder.kt deleted file mode 100644 index 404ba92f6..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/DateHolder.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.list.holder - -import android.view.ViewGroup -import com.tonapps.tonkeeper.ui.screen.events.main.list.Item -import com.tonapps.tonkeeperx.R -import uikit.extensions.setPaddingVertical -import uikit.widget.TitleView - -class DateHolder(parent: ViewGroup): Holder(parent, R.layout.view_history_header) { - - private val titleView = findViewById(R.id.title) - - init { - titleView.setPaddingVertical(0) - } - - override fun onBind(item: Item.Date) { - titleView.text = item.date - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/Holder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/Holder.kt deleted file mode 100644 index 24561478a..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/Holder.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.list.holder - -import android.view.ViewGroup -import androidx.annotation.LayoutRes -import com.tonapps.tonkeeper.ui.screen.events.main.list.Item -import com.tonapps.uikit.list.BaseListHolder - -abstract class Holder( - parent: ViewGroup, - @LayoutRes resId: Int, -): BaseListHolder(parent, resId) \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/SpaceHolder.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/SpaceHolder.kt deleted file mode 100644 index 047824668..000000000 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/main/list/holder/SpaceHolder.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.tonapps.tonkeeper.ui.screen.events.main.list.holder - -import android.view.ViewGroup -import com.tonapps.tonkeeper.ui.screen.events.main.list.Item -import com.tonapps.tonkeeperx.R - -class SpaceHolder(parent: ViewGroup): Holder(parent, R.layout.view_history_space) { - override fun onBind(item: Item.Space) { - - } - -} \ No newline at end of file diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsScreen.kt index 4999d038f..d1dcada94 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsScreen.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.ui.screen.events.spam import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import com.tonapps.tonkeeper.core.history.list.HistoryAdapter import com.tonapps.tonkeeper.core.history.list.HistoryItemDecoration @@ -10,7 +10,7 @@ import com.tonapps.tonkeeper.koin.walletViewModel import com.tonapps.tonkeeper.ui.base.BaseListWalletScreen import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.uikit.list.ListPaginationListener -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization import uikit.base.BaseFragment import uikit.extensions.collectFlow diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsViewModel.kt index fca4e3655..3342df8d3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/events/spam/SpamEventsViewModel.kt @@ -9,7 +9,7 @@ import com.tonapps.tonkeeper.ui.base.BaseWalletVM import com.tonapps.wallet.api.entity.value.Timestamp import com.tonapps.wallet.api.tron.entity.TronEventEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.events.EventsRepository import com.tonapps.wallet.data.settings.SettingsRepository import com.tonapps.wallet.localization.Localization @@ -177,12 +177,12 @@ class SpamEventsViewModel( private suspend fun getLocalSpam() = eventsRepository.getLocalSpam( accountId = wallet.accountId, - testnet = wallet.testnet + network = wallet.network ) private suspend fun getRemoteSpam(startBeforeLt: Long? = null) = eventsRepository.getRemoteSpam( accountId = wallet.accountId, - testnet = wallet.testnet, + network = wallet.network, startBeforeLt = startBeforeLt ) diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/add/KeystoneAddScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/add/KeystoneAddScreen.kt index add57dc51..80d4e0933 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/add/KeystoneAddScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/add/KeystoneAddScreen.kt @@ -14,7 +14,7 @@ import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.constantWhiteColor import com.tonapps.uikit.color.stateList import com.tonapps.ur.registry.CryptoHDKey -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignArgs.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignArgs.kt index c234f1814..e477b99d5 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignArgs.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignArgs.kt @@ -1,14 +1,14 @@ package com.tonapps.tonkeeper.ui.screen.external.qr.keystone.sign import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import com.tonapps.extensions.getParcelableCompat import com.tonapps.ur.UR import com.tonapps.ur.registry.CryptoKeypath import com.tonapps.ur.registry.TonSignRequest import com.tonapps.ur.registry.pathcomponent.IndexPathComponent import com.tonapps.ur.registry.pathcomponent.PathComponent -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.ton.crypto.hex import uikit.base.BaseArgs diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignScreen.kt index 9d57a1f0f..1f59f9907 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/external/qr/keystone/sign/KeystoneSignScreen.kt @@ -1,7 +1,7 @@ package com.tonapps.tonkeeper.ui.screen.external.qr.keystone.sign import android.os.Bundle -import android.util.Log +import com.tonapps.log.L import android.view.View import androidx.camera.view.PreviewView import androidx.lifecycle.lifecycleScope @@ -11,7 +11,7 @@ import com.tonapps.tonkeeper.ui.screen.external.qr.urFlow import com.tonapps.tonkeeperx.R import com.tonapps.ur.UREncoder import com.tonapps.ur.registry.TonSignature -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.localization.Localization import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitArgs.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitArgs.kt index 6438f6f71..f4e9857ed 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitArgs.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitArgs.kt @@ -9,7 +9,7 @@ import com.tonapps.extensions.getParcelableCompat import com.tonapps.extensions.putEnum import com.tonapps.ledger.ton.LedgerConnectData import com.tonapps.tonkeeper.ui.screen.init.list.AccountItem -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.ton.api.pub.PublicKeyEd25519 import uikit.base.BaseArgs @@ -24,7 +24,7 @@ data class InitArgs( ) : BaseArgs() { enum class Type { - New, Import, Watch, Testnet, Signer, SignerQR, Ledger, Keystone, + New, Import, Watch, Testnet, Signer, SignerQR, Ledger, Keystone, Tetra } private companion object { diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitModelState.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitModelState.kt index 5d25b05f6..b578311de 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitModelState.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitModelState.kt @@ -1,6 +1,5 @@ package com.tonapps.tonkeeper.ui.screen.init -import android.graphics.Color import android.os.Parcel import android.os.Parcelable import androidx.lifecycle.SavedStateHandle @@ -11,8 +10,9 @@ import com.tonapps.extensions.writeBooleanCompat import com.tonapps.ledger.ton.LedgerConnectData import com.tonapps.tonkeeper.ui.screen.init.list.AccountItem import com.tonapps.wallet.api.entity.AccountDetailsEntity -import com.tonapps.wallet.data.account.Wallet -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.model.legacy.WalletEntity import kotlinx.coroutines.flow.filterNotNull import org.ton.api.pub.PublicKeyEd25519 @@ -43,19 +43,6 @@ class InitModelState(private val savedStateHandle: SavedStateHandle) { } } - companion object { - private const val PASSCODE_KEY = "passcode" - private const val LABEL_KEY = "label" - private const val WATCH_ACCOUNT_KEY = "watch_account" - private const val MNEMONIC_KEY = "mnemonic" - private const val ACCOUNTS = "accounts" - private const val PUBLIC_KEY = "public_key" - private const val LEDGER_CONNECT_DATA = "ledger_connect_data" - private const val ENABLE_PUSH_KEY = "enable_push" - private const val KEYSTONE_KEY = "keystone" - private const val WORDS_COUNT_KEY = "words_count" - } - val labelFlow = savedStateHandle.getStateFlow(LABEL_KEY, null).filterNotNull() var passcode: String? @@ -103,4 +90,21 @@ class InitModelState(private val savedStateHandle: SavedStateHandle) { get() = savedStateHandle[KEYSTONE_KEY] set(value) = savedStateHandle.set(KEYSTONE_KEY, value) -} \ No newline at end of file + var backupDone: Boolean + get() = savedStateHandle[BACKUP_DONE_KEY] ?: false + set(value) = savedStateHandle.set(BACKUP_DONE_KEY, value) + + companion object { + private const val PASSCODE_KEY = "passcode" + private const val LABEL_KEY = "label" + private const val WATCH_ACCOUNT_KEY = "watch_account" + private const val MNEMONIC_KEY = "mnemonic" + private const val ACCOUNTS = "accounts" + private const val PUBLIC_KEY = "public_key" + private const val LEDGER_CONNECT_DATA = "ledger_connect_data" + private const val ENABLE_PUSH_KEY = "enable_push" + private const val KEYSTONE_KEY = "keystone" + private const val WORDS_COUNT_KEY = "words_count" + private const val BACKUP_DONE_KEY = "backup_done" + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitRoute.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitRoute.kt index 57993dda3..0b64cd032 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitRoute.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitRoute.kt @@ -6,6 +6,9 @@ sealed class InitRoute { data object ImportWords: InitRoute() data object WatchAccount: InitRoute() data object LabelAccount: InitRoute() + data object BackupStart: InitRoute() + data object BackupPhrase: InitRoute() + data object BackupCheck: InitRoute() data object SelectAccount: InitRoute() data object Push: InitRoute() -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitScreen.kt index 87dddd081..42770c7d3 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitScreen.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitScreen.kt @@ -9,6 +9,9 @@ import com.tonapps.ledger.ton.LedgerConnectData import com.tonapps.tonkeeper.ui.base.BaseWalletScreen import com.tonapps.tonkeeper.ui.base.ScreenContext import com.tonapps.tonkeeper.ui.screen.init.list.AccountItem +import com.tonapps.tonkeeper.ui.screen.init.step.BackupCheckInitScreen +import com.tonapps.tonkeeper.ui.screen.init.step.BackupPhraseScreen +import com.tonapps.tonkeeper.ui.screen.init.step.BackupStartScreen import com.tonapps.tonkeeper.ui.screen.init.step.LabelScreen import com.tonapps.tonkeeper.ui.screen.init.step.PasscodeScreen import com.tonapps.tonkeeper.ui.screen.init.step.PushScreen @@ -17,7 +20,7 @@ import com.tonapps.tonkeeper.ui.screen.init.step.WatchScreen import com.tonapps.tonkeeper.ui.screen.init.step.WordsScreen import com.tonapps.tonkeeperx.R import com.tonapps.uikit.color.backgroundPageColor -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.WalletEntity import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.ton.api.pub.PublicKeyEd25519 @@ -49,7 +52,7 @@ class InitScreen: BaseWalletScreen(R.layout.fragment_init, S } private fun onChildFragment(fragment: Fragment) { - if (fragment is PushScreen) { + if (fragment is PushScreen || fragment is BackupStartScreen) { headerView.background = null headerView.actionView.visibility = View.GONE } else { @@ -102,6 +105,13 @@ class InitScreen: BaseWalletScreen(R.layout.fragment_init, S InitRoute.LabelAccount -> LabelScreen.newInstance() InitRoute.SelectAccount -> SelectScreen.newInstance() InitRoute.Push -> PushScreen.newInstance() + InitRoute.BackupStart -> BackupStartScreen.newInstance() + InitRoute.BackupPhrase -> BackupPhraseScreen.newInstance() + InitRoute.BackupCheck -> BackupCheckInitScreen.newInstance() + } + + if (childFragmentManager.fragments.lastOrNull() is BackupCheckInitScreen) { + childFragmentManager.popBackStackImmediate() } val transaction = childFragmentManager.beginTransaction() @@ -155,4 +165,4 @@ class InitScreen: BaseWalletScreen(R.layout.fragment_init, S return fragment } } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitViewModel.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitViewModel.kt index 782dac046..4c4130b3f 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitViewModel.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/InitViewModel.kt @@ -3,7 +3,6 @@ package com.tonapps.tonkeeper.ui.screen.init import android.app.Application import android.content.Context import android.graphics.Color -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.tonapps.blockchain.MnemonicHelper @@ -18,15 +17,13 @@ import com.tonapps.blockchain.ton.extensions.EmptyPrivateKeyEd25519 import com.tonapps.blockchain.ton.extensions.toAccountId import com.tonapps.blockchain.ton.extensions.toRawAddress import com.tonapps.blockchain.ton.extensions.toWalletAddress +import com.tonapps.bus.core.AnalyticsHelper import com.tonapps.emoji.Emoji import com.tonapps.extensions.MutableEffectFlow import com.tonapps.extensions.logError import com.tonapps.icu.Coins import com.tonapps.icu.CurrencyFormatter -import com.tonapps.security.clear import com.tonapps.tonkeeper.Environment -import com.tonapps.tonkeeper.core.AnalyticsHelper -import com.tonapps.tonkeeper.extensions.consistentBucketFor import com.tonapps.tonkeeper.extensions.fixW5Title import com.tonapps.tonkeeper.manager.push.PushManager import com.tonapps.tonkeeper.ui.base.BaseWalletVM @@ -37,11 +34,12 @@ import com.tonapps.tonkeeper.worker.TotalBalancesWorker import com.tonapps.uikit.list.ListCell import com.tonapps.wallet.api.API import com.tonapps.wallet.api.entity.AccountDetailsEntity -import com.tonapps.wallet.api.entity.TokenEntity +import com.tonapps.blockchain.model.legacy.TokenEntity import com.tonapps.wallet.data.account.AccountRepository -import com.tonapps.wallet.data.account.Wallet -import com.tonapps.wallet.data.account.WalletColor -import com.tonapps.wallet.data.account.entities.WalletEntity +import com.tonapps.blockchain.model.legacy.Wallet +import com.tonapps.blockchain.model.legacy.WalletType +import com.tonapps.blockchain.model.legacy.WalletColor +import com.tonapps.blockchain.model.legacy.WalletEntity import com.tonapps.wallet.data.backup.BackupRepository import com.tonapps.wallet.data.passcode.PasscodeManager import com.tonapps.wallet.data.rn.RNLegacy @@ -52,31 +50,23 @@ import io.tonapi.models.AccountStatus import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.ton.api.pub.PublicKeyEd25519 import org.ton.block.AddrStd import org.ton.mnemonic.Mnemonic -import java.nio.ByteBuffer -import java.nio.ByteOrder import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger -import kotlin.String +@Suppress("LargeClass") @OptIn(FlowPreview::class) class InitViewModel( app: Application, @@ -102,6 +92,7 @@ class InitViewModel( private val savedState = InitModelState(savedStateHandle) private val type = args.type private val testnet: Boolean = type == InitArgs.Type.Testnet + private val tetra: Boolean = type == InitArgs.Type.Tetra private val walletsCount = AtomicInteger(-1) val watchRecoveryAccountId = args.watchRecoveryAccountId @@ -110,7 +101,11 @@ class InitViewModel( } private val tonNetwork: TonNetwork - get() = if (testnet) TonNetwork.TESTNET else TonNetwork.MAINNET + get() = when { + testnet -> TonNetwork.TESTNET + tetra -> TonNetwork.TETRA + else -> TonNetwork.MAINNET + } private val _uiTopOffset = MutableStateFlow(0) val uiTopOffset = _uiTopOffset.asStateFlow() @@ -126,14 +121,15 @@ class InitViewModel( .debounce(1000) .filter { it.isNotBlank() } .map { - val account = api.resolveAddressOrName(it, testnet) + val account = api.resolveAddressOrName(it, tonNetwork) if (account == null || account.walletVersion == WalletVersion.UNKNOWN) { setWatchAccount(null, null) return@map null } setWatchAccount(it, account) account - }.flowOn(Dispatchers.IO) + } + .flowOn(Dispatchers.IO) private val _accountsFlow = MutableEffectFlow?>() val accountsFlow = _accountsFlow.asSharedFlow().filterNotNull() @@ -143,7 +139,7 @@ class InitViewModel( private val isPinSet = AtomicBoolean(false) private val requestSetPinCode: Boolean - get() = (type == InitArgs.Type.New || type == InitArgs.Type.Import || type == InitArgs.Type.Testnet) && !isPinSet.get() + get() = (type == InitArgs.Type.New || type == InitArgs.Type.Import || type == InitArgs.Type.Testnet || type == InitArgs.Type.Tetra) && !isPinSet.get() var wordsCount: Int get() = savedState.wordsCount @@ -184,7 +180,7 @@ class InitViewModel( when (type) { InitArgs.Type.Watch -> routeTo(InitRoute.WatchAccount) - InitArgs.Type.Import, InitArgs.Type.Testnet -> routeTo(InitRoute.ImportWords) + InitArgs.Type.Import, InitArgs.Type.Testnet, InitArgs.Type.Tetra -> routeTo(InitRoute.ImportWords) InitArgs.Type.Signer, InitArgs.Type.SignerQR -> resolveWallets(savedState.publicKey!!) InitArgs.Type.Ledger -> routeTo(InitRoute.SelectAccount) InitArgs.Type.Keystone -> { @@ -199,12 +195,9 @@ class InitViewModel( InitArgs.Type.New -> { if (requestSetPinCode) { routeTo(InitRoute.CreatePasscode) - } else if (environment.isGooglePlayServicesAvailable) { - routeTo(InitRoute.Push) } else { - routeTo(InitRoute.LabelAccount) + routeTo(InitRoute.BackupStart) } - generateNewWallet() } } } @@ -214,6 +207,35 @@ class InitViewModel( setLoading(false) } + fun getMnemonic(): List? = savedState.mnemonic + + fun navigateToBackupPhrase() { + viewModelScope.launch { + generateNewWallet() + routeTo(InitRoute.BackupPhrase) + } + } + + fun navigateToBackupCheck() { + routeTo(InitRoute.BackupCheck) + } + + fun completeBackup(done: Boolean = true) { + savedState.backupDone = done + if (environment.isGooglePlayServicesAvailable) { + routeTo(InitRoute.Push) + } else { + routeTo(InitRoute.LabelAccount) + } + } + + fun skipBackup() { + viewModelScope.launch { + generateNewWallet() + completeBackup(done = false) + } + } + fun enablePush(enable: Boolean) { savedState.enablePush = enable routeTo(InitRoute.LabelAccount) @@ -264,7 +286,13 @@ class InitViewModel( } else if (watchRecoveryAccountId != null) { execute(context) } else { - routeTo(if (environment.isGooglePlayServicesAvailable) InitRoute.Push else InitRoute.LabelAccount) + if (type == InitArgs.Type.New) { + routeTo(InitRoute.BackupStart) + } else if (environment.isGooglePlayServicesAvailable) { + routeTo(InitRoute.Push) + } else { + routeTo(InitRoute.LabelAccount) + } } } @@ -294,25 +322,26 @@ class InitViewModel( val accounts = if (publicKey.new) { mutableListOf() } else { - api.resolvePublicKey(publicKey.publicKey, testnet).filter { + api.resolvePublicKey(publicKey.publicKey, tonNetwork).filter { it.walletVersion != WalletVersion.UNKNOWN }.sortedByDescending { it.walletVersion.index }.toMutableList() } if (accounts.count { it.walletVersion == WalletVersion.V5R1 } == 0) { - val contract = WalletV5R1Contract(publicKey.publicKey, tonNetwork) + val network = if (tonNetwork.isTetra) TonNetwork.TETRA else tonNetwork + val contract = WalletV5R1Contract(publicKey.publicKey, network) val query = contract.address.toAccountId() if (publicKey.new) { accounts.add( 0, - AccountDetailsEntity(contract, testnet, new = true, initialized = false) + AccountDetailsEntity(contract, tonNetwork, new = true, initialized = false) ) } else { - val apiAccount = api.resolveAccount(query, testnet) + val apiAccount = api.resolveAccount(query, tonNetwork) val account = if (apiAccount == null) { AccountDetailsEntity( contract, - testnet = testnet, + tonNetwork, new = true, initialized = false ) @@ -320,7 +349,7 @@ class InitViewModel( AccountDetailsEntity( query, apiAccount.copy( interfaces = listOf("wallet_v5r1") - ), testnet, false + ), tonNetwork, false ) } accounts.add(0, account) @@ -332,9 +361,10 @@ class InitViewModel( } val recoveryWallet = getRecoveryWatchWallet(publicKey = publicKey.publicKey) - val recoveryAccountItem = accounts.firstOrNull { it.preview.accountId == recoveryWallet?.accountId }?.let { - getAccountItem(it, ListCell.Position.SINGLE) - } + val recoveryAccountItem = + accounts.firstOrNull { it.preview.accountId == recoveryWallet?.accountId }?.let { + getAccountItem(it, ListCell.Position.SINGLE) + } val items = mutableListOf() if (recoveryAccountItem != null) { @@ -419,8 +449,8 @@ class InitViewModel( initialized = false ) } else { - val tokensDeferred = async { api.getJettonsBalances(account.address, testnet) } - val nftItemsDeferred = async { api.getNftItems(account.address, testnet, 1) } + val tokensDeferred = async { api.getJettonsBalances(account.address, tonNetwork) } + val nftItemsDeferred = async { api.getNftItems(account.address, tonNetwork, 1) } val tokens = tokensDeferred.await() ?: emptyList() val nftItems = nftItemsDeferred.await() ?: emptyList() val balance = Coins.of(account.balance) @@ -464,7 +494,7 @@ class InitViewModel( } val publicKey = account?.address?.let { - api.safeGetPublicKey(it, testnet) + api.safeGetPublicKey(it, tonNetwork) } setPublicKey(publicKey) @@ -568,20 +598,21 @@ class InitViewModel( MnemonicHelper.privateKey(mnemonic).publicKey() ) - suspend fun getRecoveryWatchWallet(publicKey: PublicKeyEd25519): WalletEntity? = withContext(Dispatchers.IO) { - if (watchRecoveryAccountId == null) { - return@withContext null - } + suspend fun getRecoveryWatchWallet(publicKey: PublicKeyEd25519): WalletEntity? = + withContext(Dispatchers.IO) { + if (watchRecoveryAccountId == null) { + return@withContext null + } - val wallet = accountRepository.getWallets() - .first { it.accountId == watchRecoveryAccountId && it.type == Wallet.Type.Watch } + val wallet = accountRepository.getWallets() + .first { it.accountId == watchRecoveryAccountId && it.type == WalletType.Watch } - if (wallet.publicKey == publicKey) { - wallet - } else { - null + if (wallet.publicKey == publicKey) { + wallet + } else { + null + } } - } private fun execute(context: Context) { setLoading(true) @@ -593,9 +624,9 @@ class InitViewModel( } val alreadyWalletCount = accountRepository.getWallets() - if (alreadyWalletCount.isEmpty() && api.config.flags.safeModeEnabled) { + if (alreadyWalletCount.isEmpty() && api.getConfig(TonNetwork.MAINNET).flags.safeModeEnabled) { settingsRepository.setSafeModeState(SafeModeState.Enabled) - if (type == InitArgs.Type.Import || type == InitArgs.Type.Testnet) { + if (type == InitArgs.Type.Import || type == InitArgs.Type.Testnet || type == InitArgs.Type.Tetra) { settingsRepository.showSafeModeSetup = true } } @@ -604,7 +635,7 @@ class InitViewModel( when (type) { InitArgs.Type.Watch -> wallets.add(saveWatchWallet()) InitArgs.Type.New -> wallets.add(newWallet(context)) - InitArgs.Type.Import, InitArgs.Type.Testnet -> wallets.addAll( + InitArgs.Type.Import, InitArgs.Type.Testnet, InitArgs.Type.Tetra -> wallets.addAll( importWallet( context ) @@ -616,7 +647,7 @@ class InitViewModel( InitArgs.Type.Keystone -> wallets.addAll(keystoneWallet()) } - if (type == InitArgs.Type.Import || type == InitArgs.Type.Testnet) { + if (type == InitArgs.Type.Import || type == InitArgs.Type.Testnet || type == InitArgs.Type.Tetra || savedState.backupDone) { backupRepository.addBackups(wallets.map { it.id }) } @@ -699,6 +730,7 @@ class InitViewModel( } private suspend fun generateNewWallet() = withContext(Dispatchers.IO) { + setLoading(true) AndroidSecureRandom.seed(entropyHelper.getSeed(512)) val mnemonic = Mnemonic.generate(random = AndroidSecureRandom) savedState.mnemonic = mnemonic @@ -712,6 +744,7 @@ class InitViewModel( emoji = emoji?.toString() ?: Emoji.WALLET_ICON, color = color ?: WalletColor.all.first() ) + setLoading(false) } private suspend fun newWallet(context: Context): WalletEntity { @@ -766,12 +799,18 @@ class InitViewModel( ) } + val type = when { + testnet -> WalletType.Testnet + tetra -> WalletType.Tetra + else -> WalletType.Default + } + val wallets = accountRepository.importWallet( ids, label, mnemonic, accounts.map { it.walletVersion }, - testnet, + type, accounts.map { it.initialized }) // delete watch only wallet @@ -780,7 +819,7 @@ class InitViewModel( accountRepository.delete(it) } - if (!testnet) { + if (!testnet && !tetra) { checkTronBalance(wallets) } @@ -866,10 +905,10 @@ class InitViewModel( val contact = BaseWalletContract.create( publicKey.publicKey, WalletVersion.V4R2.title, - tonNetwork.value + tonNetwork ) val account = - api.resolveAccount(contact.address.toWalletAddress(testnet = testnet), testnet) + api.resolveAccount(contact.address.toWalletAddress(testnet = testnet), tonNetwork) val initialized = account != null && (account.status == AccountStatus.active || account.status == AccountStatus.frozen) @@ -919,4 +958,4 @@ class InitViewModel( version = account.walletVersion ) } -} \ No newline at end of file +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/WalletLabelHelper.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/WalletLabelHelper.kt index 41f81d4df..d8161c5ec 100644 --- a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/WalletLabelHelper.kt +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/WalletLabelHelper.kt @@ -6,7 +6,7 @@ import com.tonapps.blockchain.tron.isValidTronAddress import com.tonapps.emoji.Emoji import com.tonapps.tonkeeper.extensions.consistentBucketFor import com.tonapps.tonkeeper.extensions.isNullOrEmpty -import com.tonapps.wallet.data.account.WalletColor +import com.tonapps.blockchain.model.legacy.WalletColor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.builtins.PairSerializer diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/step/BackupCheckInitScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/step/BackupCheckInitScreen.kt new file mode 100644 index 000000000..a4362326a --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/step/BackupCheckInitScreen.kt @@ -0,0 +1,130 @@ +package com.tonapps.tonkeeper.ui.screen.init.step + +import android.os.Bundle +import android.view.View +import android.widget.Button +import androidx.core.view.updatePadding +import androidx.core.widget.NestedScrollView +import com.tonapps.tonkeeper.extensions.hideKeyboard +import com.tonapps.tonkeeper.ui.screen.init.InitViewModel +import com.tonapps.tonkeeperx.BuildConfig +import com.tonapps.tonkeeperx.R +import com.tonapps.wallet.localization.Localization +import org.koin.androidx.viewmodel.ext.android.viewModel +import uikit.base.BaseFragment +import uikit.extensions.collectFlow +import uikit.extensions.doKeyboardAnimation +import uikit.extensions.scrollView +import uikit.widget.HeaderView +import uikit.widget.TextHeaderView +import uikit.widget.WordInput + +class BackupCheckInitScreen : BaseFragment(R.layout.fragment_backup_check) { + + override val fragmentName: String = "BackupCheckInitScreen" + + override val secure: Boolean = !BuildConfig.DEBUG + + private val initViewModel: InitViewModel by viewModel(ownerProducer = { requireParentFragment() }) + + private val words: Array by lazy { + initViewModel.getMnemonic()?.toTypedArray() ?: emptyArray() + } + + private val indexes: IntArray by lazy { + words.indices.shuffled().take(3.coerceAtMost(words.size)) + .sorted() + .toIntArray() + } + + private lateinit var button: Button + private lateinit var wordInputs: List + private lateinit var scrollView: NestedScrollView + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.header).visibility = View.GONE + + view.findViewById(R.id.text).desciption = + getString( + Localization.backup_check_subtitle, + indexes[0] + 1, + indexes[1] + 1, + indexes[2] + 1 + ) + + scrollView = view.findViewById(R.id.scroll) + + wordInputs = listOf( + view.findViewById(R.id.word_input_1), + view.findViewById(R.id.word_input_2), + view.findViewById(R.id.word_input_3) + ) + + for (i in wordInputs.indices) { + val hasNext = i < wordInputs.size - 1 + val hasPrev = i > 0 + val wordInput = wordInputs[i] + wordInput.setIndex(indexes[i] + 1) + wordInput.doOnNext = { + if (hasNext) wordInputs[i + 1].focus(true) else confirmBackup() + } + wordInput.doOnPrev = { + if (hasPrev) wordInputs[i - 1].focus(false) + } + wordInput.doOnTextChanged = { checkEnableButton() } + wordInput.doOnFocus = { focus -> + if (focus) scrollView.postOnAnimation { scrollView.scrollView(wordInput) } + checkWords() + } + } + + button = view.findViewById(R.id.done) + button.isEnabled = false + button.setOnClickListener { confirmBackup() } + button.text = getString(Localization.continue_action) + + scrollView.doKeyboardAnimation { offset, progress, _ -> + scrollView.updatePadding(bottom = offset + button.height) + button.translationY = -offset.toFloat() + if (progress >= .9f || .1f >= progress) { + getCurrentFocus()?.let { scrollView.postOnAnimation { scrollView.scrollView(it) } } + } + } + + collectFlow(initViewModel.uiTopOffset) { + scrollView.updatePadding(top = it) + } + + wordInputs.first().focus(true) + } + + private fun confirmBackup() { + if (button.isEnabled) { + hideKeyboard() + initViewModel.completeBackup() + } + } + + private fun checkEnableButton() { + val inputWords = wordInputs.map { it.text.trim() }.filter { it.isNotEmpty() } + button.isEnabled = if (inputWords.size != indexes.size) { + false + } else { + inputWords == indexes.map { words[it] } + } + } + + private fun checkWords() { + for (i in wordInputs.indices) { + val wordInput = wordInputs[i] + val word = wordInput.text.trim() + if (word.isBlank() || wordInput.isFocused) continue + wordInput.setError(word != words[indexes[i]]) + } + } + + companion object { + fun newInstance() = BackupCheckInitScreen() + } +} diff --git a/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/step/BackupPhraseScreen.kt b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/step/BackupPhraseScreen.kt new file mode 100644 index 000000000..98ddf504b --- /dev/null +++ b/apps/wallet/instance/app/src/main/java/com/tonapps/tonkeeper/ui/screen/init/step/BackupPhraseScreen.kt @@ -0,0 +1,92 @@ +package com.tonapps.tonkeeper.ui.screen.init.step + +import android.content.res.Configuration +import android.os.Bundle +import android.util.TypedValue +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import com.tonapps.tonkeeper.ui.screen.init.InitViewModel +import com.tonapps.tonkeeperx.BuildConfig +import com.tonapps.tonkeeperx.R +import org.koin.androidx.viewmodel.ext.android.viewModel +import uikit.base.BaseFragment +import uikit.extensions.getDimensionPixelSize +import uikit.widget.PhraseWords +import uikit.widget.TextHeaderView + +class BackupPhraseScreen : BaseFragment(R.layout.fragment_backup_phrase) { + + override val fragmentName: String = "BackupPhraseScreen" + + override val secure: Boolean = !BuildConfig.DEBUG + + private val initViewModel: InitViewModel by viewModel(ownerProducer = { requireParentFragment() }) + + private lateinit var textHeaderView: TextHeaderView + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val config = + Configuration(requireContext().resources.configuration).apply { fontScale = 1f } + val configContext = requireContext().createConfigurationContext(config) + val themedContext = ContextThemeWrapper(configContext, requireContext().theme) + val noScaleInflater = inflater.cloneInContext(themedContext) + return super.onCreateView(noScaleInflater, container, savedInstanceState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + textHeaderView = view.findViewById(R.id.text_header) + val wordsView = view.findViewById(R.id.words) + val mnemonic = initViewModel.getMnemonic() + if (mnemonic != null) { + wordsView.setWords(mnemonic) + } + + val button = view.findViewById