Compare commits

..

4 Commits

Author SHA1 Message Date
Andriy Druk 6d2d7fbf50 Add simple C binding for iOS and Android projects (#120)
* Dev: add simple C binding for iOS and Android projects

* System: add ci script
2021-11-04 07:52:38 +00:00
Evgeny Poberezkin 7eea1a6178 save header file 2021-10-30 16:51:13 +01:00
Evgeny Poberezkin bc69bcb929 commit ios/android stubs 2021-10-30 16:43:19 +01:00
Evgeny Poberezkin 5cba18120b move haskell implementation to a folder (#108)
* move haskell implementation to a folder

* build v5 branch

* fixing CI
2021-10-02 10:10:35 +01:00
308 changed files with 7231 additions and 21902 deletions
-1
View File
@@ -1 +0,0 @@
* @epoberezkin @efim-poberezkin
-1
View File
@@ -1,2 +1 @@
github: simplex-chat
open_collective: simplex-chat
+2 -2
View File
@@ -1,4 +1,4 @@
{
"template": "Commits:\n${{UNCATEGORIZED}}",
"pr_template": "- ${{TITLE}}"
"template": "${{UNCATEGORIZED}}",
"pr_template": "- ${{TITLE}}\n"
}
+19 -44
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- master
- stable
- v5
tags:
- "v*"
pull_request:
@@ -32,7 +32,6 @@ jobs:
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true
files: |
LICENSE
fail_on_unmatched_files: true
@@ -50,15 +49,24 @@ jobs:
include:
- os: ubuntu-20.04
cache_path: ~/.stack
stack_args: "--test"
artifact_rel_path: /bin/simplex-chat
asset_name: simplex-chat-ubuntu-20_04-x86-64
- os: ubuntu-18.04
cache_path: ~/.stack
stack_args: "--test"
artifact_rel_path: /bin/simplex-chat
asset_name: simplex-chat-ubuntu-18_04-x86-64
- os: macos-latest
cache_path: ~/.stack
stack_args: "--test"
artifact_rel_path: /bin/simplex-chat
asset_name: simplex-chat-macos-x86-64
# TODO enable tests for windows once fixed (remove stack_args altogether)
- os: windows-latest
cache_path: C:/sr
stack_args: ""
artifact_rel_path: /bin/simplex-chat.exe
asset_name: simplex-chat-windows-x86-64
steps:
- name: Clone project
@@ -67,7 +75,7 @@ jobs:
- name: Setup Stack
uses: haskell/actions/setup@v1
with:
ghc-version: '8.10.7'
ghc-version: '8.8.4'
enable-stack: true
stack-version: 'latest'
@@ -77,51 +85,18 @@ jobs:
path: ${{ matrix.cache_path }}
key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }}
# / Unix
- name: Unix build
id: unix_build
if: matrix.os != 'windows-latest'
shell: bash
- name: Build & test
id: build_test
working-directory: ./haskell
run: |
stack build --test
echo "::set-output name=local_install_root::$(stack path --local-install-root)"
stack build ${{ matrix.stack_args }}
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
- name: Unix upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
- name: Upload binaries to release
if: startsWith(github.ref, 'refs/tags/v')
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat
file: ${{ steps.build_test.outputs.LOCAL_INSTALL_ROOT }}${{ matrix.artifact_rel_path }}
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
# Unix /
# / Windows
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
# * So we're running a separate set of actions for Windows build
# TODO run tests on Windows
- name: Windows build
id: windows_build
if: matrix.os == 'windows-latest'
shell: cmd
run: |
stack build
stack path --local-install-root > tmp_file
set /p local_install_root= < tmp_file
echo ::set-output name=local_install_root::%local_install_root%
- name: Windows upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.windows_build.outputs.local_install_root }}\bin\simplex-chat.exe
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
# Windows /
+17
View File
@@ -0,0 +1,17 @@
name: Chat Lib Tests
on: [push]
jobs:
test:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v1
- name: Test
run: cd packages/chat;
cmake . -Bbuild;
cd build;
cmake --build .;
ctest --verbose
-36
View File
@@ -1,36 +0,0 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
# Beta Release
uses: cla-assistant/github-action@v2.1.3-beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
path-to-signatures: 'signatures/v1.1/cla.json'
path-to-document: 'https://github.com/simplex-chat/cla/blob/master/CLA.md'
# branch should not be protected
remote-organization-name: simplex-chat
remote-repository-name: cla
branch: 'master'
# allowlist: user1,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA
+17 -4
View File
@@ -5,6 +5,12 @@
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
@@ -34,11 +40,18 @@ cabal.project.local
cabal.project.local~
.HTF/
.ghc.environment.*
*.cabal
stack.yaml.lock
# Chat database
# Idris
*.ibc
# chat database
*.db
*.db.bak
# Temporary test files
tests/tmp
packages/android/.idea
packages/chat/.idea
packages/chat/build
packages/chat/Testing
packages/ios/SimpleX Chat.xcodeproj/project.xcworkspace/xcuserdata
packages/ios/SimpleX Chat.xcodeproj/xcuserdata
+51 -108
View File
@@ -1,28 +1,19 @@
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
<img align="right" src="images/logo.svg" alt="SimpleX logo" height="90">
# SimpleX Chat
# SimpleX chat
SimpleX - private and secure open-source chat and application platform - public beta for iOS now available!
## Private, secure, decentralized
[![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/simplexchat)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
SimpleX Chat apps (both terminal UI and [iOS public beta](https://testflight.apple.com/join/DWuT2LQu)) use [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker.
> **NEW in v0.4: [groups](#groups) and [sending files](#sending-files)!**
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
The motivation for SimpleX chat is [presented here](./simplex.md).
***SimpleX Chat [public beta for iOS 15 is available via TestFlight](https://testflight.apple.com/join/DWuT2LQu)** - it will help us a lot if you test it! [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220214-simplex-chat-ios-public-beta.md).*
SimpleX chat prototype is a thin terminal UI on top of [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker that uses [SMP protocols](https://github.com/simplex-chat/simplexmq/blob/master/protocol).
### :zap: Quick installation
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
```
Once the chat client is installed, simply run `simplex-chat` from your terminal.
See [simplex.chat](https://simplex.chat) website for chat demo and the explanations of the system and how SMP protocol works.
![simplex-chat](./images/connection.gif)
@@ -31,10 +22,8 @@ Once the chat client is installed, simply run `simplex-chat` from your terminal.
- [Disclaimer](#disclaimer)
- [Network topology](#network-topology)
- [Terminal chat features](#terminal-chat-features)
- [Installation](#🚀-installation)
- [Installation](#installation)
- [Download chat client](#download-chat-client)
- [Linux and MacOS](#linux-and-macos)
- [Windows](#windows)
- [Build from source](#build-from-source)
- [Using Docker](#using-docker)
- [Using Haskell stack](#using-haskell-stack)
@@ -43,26 +32,25 @@ Once the chat client is installed, simply run `simplex-chat` from your terminal.
- [How to use SimpleX chat](#how-to-use-simplex-chat)
- [Groups](#groups)
- [Sending files](#sending-files)
- [User contact addresses](#user-contact-addresses)
- [Access chat history](#access-chat-history)
- [Roadmap](#Roadmap)
- [Future roadmap](#future-roadmap)
- [License](#license)
## Disclaimer
SimpleX Chat implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
This is WIP implementation of SimpleX chat that implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
[SimpleXMQ security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) had many improvements in v1.0.0; the implementation has not been audited yet.
If you expect a software being reliable most of the time and doing something useful, then this is probably not ready for you yet. We do use it for terminal chat though, and it seems to work most of the time - we would really appreciate if you try it and give us your feedback.
We use SimpleX Chat all the time, but you may find some bugs. We would really appreciate if you use it and let us know anything that needs to be fixed or improved.
**Please note:** The main differentiation of SimpleX network is the approach to internet message routing rather than encryption; for that reason no sufficient attention was paid to either TCP transport level encryption or to E2E encryption protocols - they are implemented in an ad hoc way based on RSA and AES algorithms. See [SMP protocol](https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a) on TCP transport encryption protocol (AEAD-GCM scheme, with an AES key negotiation based on RSA key hash known to the client in advance) and [this section](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2021-01-26-crypto.md#e2e-encryption) on E2E encryption protocol (an ad hoc hybrid scheme a la PGP). These protocols will change in a consumer ready version to something more robust.
## Network topology
SimpleX is a client-server network that uses redundant, disposable nodes to asynchronously pass messages via message queues, providing receiver and sender anonymity.
SimpleX is a decentralized client-server network that uses redundant, disposable nodes to asynchronously pass the messages via message queues, providing receiver and sender anonymity.
Unlike P2P networks, all messages are passed through one or several (for redundancy) servers, that do not even need to have persistence (in fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records) - it provides better metadata protection than P2P designs, as no global participant ID is required, and avoids many [problems of P2P networks](https://github.com/simplex-chat/simplex-chat/blob/master/simplex.md#comparison-with-p2p-messaging-protocols).
Unlike federated networks, the participating server nodes **do not have records of the users**, **do not communicate with each other**, **do not store messages** after they are delivered to the recipients, and there is no way to discover the full list of participating servers. SimpleX network avoids the problem of metadata visibility that federated networks have and better protects the network, as servers do not communicate with each other. Each server node provides unidirectional "dumb pipes" to the users, that do authorization without authentication, having no knowledge of the the users or their contacts. Each queue is assigned two Ed448 keys - one for receiver and one for sender - and each queue access is authorized with a signature created using a respective key's private counterpart.
Unlike federated networks, the participating server nodes do NOT have records of the users, do NOT communicate with each other, do NOT store messages after they are delivered to the recipients, and there is no way to discover the full list of participating servers - it avoids the problem of metadata visibility that federated networks suffer from and better protects the network, as servers do not communicate with each other. Each server node provides unidirectional "dumb pipes" to the users, that do authorization without authentication, having no knowledge of the the users or their contacts. Each queue is assigned two RSA keys - one for receiver and one for sender - and each queue access is authorized with a signature created using a respective key's private counterpart.
The routing of messages relies on the knowledge of client devices how user contacts and groups map at any given moment of time to these disposable queues on server nodes.
@@ -71,49 +59,31 @@ The routing of messages relies on the knowledge of client devices how user conta
- 1-to-1 chat with multiple people in the same terminal window.
- Group messaging.
- Sending files to contacts and groups.
- User contact addresses - establish connections via multiple-use contact links.
- Messages persisted in a local SQLite database.
- Auto-populated recipient name - just type your messages to reply to the sender once the connection is established.
- Demo SMP servers available and pre-configured in the app - or you can [deploy your own server](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent).
- No global identity or any names visible to the server(s), ensuring full privacy of your contacts and conversations.
- Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)).
- E2E encryption, with RSA public key that has to be passed out-of-band (see [How to use SimpleX chat](#how-to-use-simplex-chat)).
- Message signing and verification with automatically generated RSA keys.
- Message integrity validation (via including the digests of the previous messages).
- Authentication of each command/message by SMP servers with automatically generated Ed448 keys.
- TLS 1.3 transport encryption.
- Additional encryption of messages from SMP server to recipient to reduce traffic correlation.
- Authentication of each command/message by SMP servers with automatically generated RSA key pairs.
- TCP transport encryption using SMP transport protocol.
Public keys involved in key exchange are not used as identity, they are randomly generated for each contact.
RSA keys are not used as identity, they are randomly generated for each contact.
See [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used) for technical details.
<a name="🚀-installation"></a>
## 🚀 Installation
## Installation
### Download chat client
Download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below.
#### Linux and MacOS
To **install** or **update** `simplex-chat`, you should run the install script. To do that, use the following cURL or Wget command:
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
```
```sh
wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
```
Once the chat client downloads, you can run it with `simplex-chat` command in your terminal.
Alternatively, you can manually download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below.
```sh
chmod +x <binary>
mv <binary> ~/.local/bin/simplex-chat
```
(or any other preferred location on `PATH`).
(or any other preferred location on PATH).
On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491).
@@ -125,8 +95,6 @@ move <binary> %APPDATA%/local/bin/simplex-chat.exe
### Build from source
> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable).
#### Using Docker
On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs):
@@ -134,11 +102,10 @@ On Linux, you can build the chat executable using [docker build with custom outp
```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin .
```
> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
> **Please note:** If you encounter ``version `GLIBC_2.28' not found`` error, rebuild it with `haskell:8.8.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
#### Using Haskell stack
@@ -153,7 +120,6 @@ and build the project:
```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ stack install
```
@@ -163,7 +129,7 @@ $ stack install
To start the chat client, run `simplex-chat` from the terminal.
By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex_v1_chat.db` and `simplex_v1_agent.db` are initialized in it.
By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex.chat.db` and `simplex.agent.db` are initialized in it.
To specify a different file path prefix for the database files use `-d` command line option:
@@ -171,17 +137,17 @@ To specify a different file path prefix for the database files use `-d` command
$ simplex-chat -d alice
```
Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory.
Running above, for example, would create `alice.chat.db` and `alice.agent.db` database files in current directory.
Three default SMP servers are hosted on Linode - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L42).
Default SMP servers are hosted on Linode (London, UK and Fremont, CA) - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L40). Base-64 encoded string after server host is the transport key digest.
If you deployed your own SMP server(s) you can configure client via `-s` option:
```shell
$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com
$ simplex-chat -s smp.example.com:5223#KXNE1m2E1m0lm92WGKet9CL6+lO742Vy5G6nsrkvgs8=
```
Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake.
The base-64 encoded string in server address is the digest of RSA transport handshake key that the server will generate on the first run and output its digest.
You can still talk to people using default or any other server - it only affects the location of the message queue when you initiate the connection (and the reply queue can be on another server, as set by the other party's client).
@@ -189,9 +155,9 @@ Run `simplex-chat -h` to see all available options.
### How to use SimpleX chat
Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. If some of your contacts chose the same display name, the chat client adds a numeric suffix to their local display name.
Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. In case different contacts chose the same display name, the chat client adds a numeric suffix to their local display names.
The diagram below shows how to connect and message a contact:
This diagram shows how to connect and message a contact:
<div align="center">
<img align="center" src="images/how-to-use-simplex.svg">
@@ -201,7 +167,7 @@ Once you've set up your local profile, enter `/c` (for `/connect`) to create a n
You are able to create multiple invitations by entering `/connect` multiple times and sending these invitations to the corresponding contacts you'd like to connect with.
The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established. See agent protocol for explanation of [invitation format](https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md#connection-request).
The invitation has the format `smp::<server>::<queue_id>::<rsa_public_key_for_this_queue_only>`. The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established.
The contact who received the invitation should enter `/c <invitation>` to accept the connection. This establishes the connection, and both parties are notified.
@@ -211,7 +177,7 @@ Use `/help` in chat to see the list of available commands.
### Groups
To create a group use `/g <group>`, then add contacts to it with `/a <group> <name>`. You can then send messages to the group by entering `#<group> <message>`. Use `/help groups` for other commands.
To create a group use `/g <group>`, then add contacts to it with `/a <group> <name>`and send messages with `#<group> <message>`. Use `/help groups` for other commands.
![simplex-chat](./images/groups.gif)
@@ -225,62 +191,39 @@ You can send a file to your contact with `/f @<contact> <file_path>` - the recip
You can send files to a group with `/f #<group> <file_path>`.
### User contact addresses
As an alternative to one-time invitation links, you can create a long-term address with `/ad` (for `/address`). The created address can then be shared via any channel, and used by other users as a link to make a contact request with `/c <user_contact_address>`.
You can accept or reject incoming requests with `/ac <name>` and `/rc <name>` commands.
User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you).
Use `/help address` for other commands.
![simplex-chat](./images/user-addresses.gif)
### Access chat history
SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user.
> 🚧 **Section currently out of date - will be updated soon** 🏗
You can view and search your chat history by querying your database. Run the below script to create message views in your database.
SimpleX chat stores all your contacts and conversations in a local database file, making it private and portable by design, fully owned and controlled by you.
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db
You can search your chat history via SQLite database file:
```
sqlite3 ~/.simplex/smp-chat.db
```
Open SQLite Command Line Shell:
Now you can query `messages` table, for example:
```sh
sqlite3 ~/.simplex/simplex_v1_chat.db
```sql
select * from messages
where conn_alias = cast('alice' as blob)
and body like '%cats%'
order by internal_id desc;
```
See [Message queries](./message_queries.md) for examples.
> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state.
**Convenience queries**
Get all messages from today (`chat_dt` is in UTC):
```sql
select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt;
```
Get overnight messages in the morning:
```sql
select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt;
```
## Roadmap
## Future roadmap
1. Mobile and desktop apps (in progress).
2. SMP protocol improvements:
- SMP queue redundancy and rotation.
- Message delivery confirmation.
- Support multiple devices.
- SMP queue redundancy and rotation.
- Message delivery confirmation.
- Support multiple devices.
3. Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
- keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups.
- keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups.
4. Media server to optimize sending large files to groups.
5. Channels server for large groups and broadcast channels.
-1
View File
@@ -1 +0,0 @@
SimpleX
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>
@@ -1,20 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
-92
View File
@@ -1,92 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
}
android {
compileSdk 32
defaultConfig {
applicationId "chat.simplex.app"
minSdk 26
targetSdk 32
versionCode 3
versionName "0.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters 'arm64-v8a'
}
vectorDrawables {
useSupportLibrary true
}
externalNativeBuild {
cmake {
cppFlags ''
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
version '3.10.2'
}
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.activity:activity-compose:1.4.0'
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
implementation "androidx.compose.material:material-icons-extended:$compose_version"
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
def camerax_version = "1.1.0-beta01"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
implementation "androidx.camera:camera-view:${camerax_version}"
//Barcode
implementation 'com.google.zxing:core:3.4.0'
implementation 'com.google.mlkit:barcode-scanning:17.0.2'
//Camera Permission
implementation "com.google.accompanist:accompanist-permissions:0.23.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}
@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="chat.simplex.app">
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.SimpleX">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="simplex" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,50 +0,0 @@
#include <jni.h>
// from the RTS
void hs_init(int * argc, char **argv[]);
// from android-support
void setLineBuffering(void);
int pipe_std_to_socket(const char * name);
JNIEXPORT jint JNICALL
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
int ret = pipe_std_to_socket(name);
(*env)->ReleaseStringUTFChars(env, socket_name, name);
return ret;
}
JNIEXPORT void JNICALL
Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
hs_init(NULL, NULL);
setLineBuffering();
}
// from simplex-chat
typedef void* chat_ctrl;
extern chat_ctrl chat_init(const char * path);
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
extern char *chat_recv_msg(chat_ctrl ctrl);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_SimplexAppKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
jlong res = (jlong)chat_init(_data);
(*env)->ReleaseStringUTFChars(env, datadir, _data);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_SimplexAppKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

@@ -1,181 +0,0 @@
package chat.simplex.app
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.lifecycle.AndroidViewModel
import androidx.navigation.*
import androidx.navigation.compose.*
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.*
import chat.simplex.app.views.chat.ChatInfoView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ExperimentalAnimatedInsets
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.serialization.decodeFromString
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
class MainActivity: ComponentActivity() {
private val vm by viewModels<SimplexViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
connectIfOpenedViaUri(intent, vm.chatModel)
setContent {
SimpleXTheme {
Navigation(vm.chatModel)
}
}
}
}
@DelicateCoroutinesApi
class SimplexViewModel(application: Application): AndroidViewModel(application) {
val chatModel = getApplication<SimplexApp>().chatModel
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun MainPage(chatModel: ChatModel, nav: NavController) {
when (chatModel.userCreated.value) {
null -> SplashView()
false -> WelcomeView(chatModel) { nav.navigate(Pages.ChatList.route) }
true -> ChatListView(chatModel, nav)
}
}
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun Navigation(chatModel: ChatModel) {
val nav = rememberNavController()
Box {
NavHost(navController = nav, startDestination = Pages.Home.route) {
composable(route = Pages.Home.route) {
MainPage(chatModel, nav)
}
composable(route = Pages.Welcome.route) {
WelcomeView(chatModel) {
nav.navigate(Pages.Home.route) {
popUpTo(Pages.Home.route) { inclusive = true }
}
}
}
composable(route = Pages.ChatList.route) {
ChatListView(chatModel, nav)
}
composable(route = Pages.Chat.route) {
ChatView(chatModel, nav)
}
composable(route = Pages.AddContact.route) {
AddContactView(chatModel, nav)
}
composable(route = Pages.Connect.route) {
ConnectContactView(chatModel, nav)
}
composable(route = Pages.ChatInfo.route) {
ChatInfoView(chatModel, nav)
}
composable(route = Pages.Terminal.route) {
TerminalView(chatModel, nav)
}
composable(
Pages.TerminalItemDetails.route + "/{identifier}",
arguments = listOf(
navArgument("identifier") {
type = NavType.LongType
}
)
) { entry -> DetailView(entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) }
composable(route = Pages.UserProfile.route) {
UserProfileView(chatModel, nav)
}
composable(route = Pages.UserAddress.route) {
UserAddressView(chatModel, nav)
}
composable(route = Pages.Help.route) {
HelpView(chatModel, nav)
}
composable(route = Pages.Markdown.route) {
MarkdownHelpView(nav)
}
}
val am = chatModel.alertManager
if (am.presentAlert.value) am.alertView.value?.invoke()
}
}
sealed class Pages(val route: String) {
object Home: Pages("home")
object Terminal: Pages("terminal")
object Welcome: Pages("welcome")
object TerminalItemDetails: Pages("details")
object ChatList: Pages("chats")
object Chat: Pages("chat")
object AddContact: Pages("add_contact")
object Connect: Pages("connect")
object ChatInfo: Pages("chat_info")
object UserProfile: Pages("user_profile")
object UserAddress: Pages("user_address")
object Help: Pages("help")
object Markdown: Pages("markdown")
}
@DelicateCoroutinesApi
fun connectIfOpenedViaUri(intent: Intent?, chatModel: ChatModel) {
val uri = intent?.data
if (intent?.action == "android.intent.action.VIEW" && uri != null) {
Log.d("SIMPLEX", "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = uri
} else {
withUriAction(chatModel, uri) { action ->
chatModel.alertManager.showAlertMsg(
title = "Connect via $action link?",
text = "Your profile will be sent to the contact that you received this link from.",
confirmText = "Connect",
onConfirm = {
withApi {
Log.d("SIMPLEX", "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, action, uri)
}
}
)
}
}
}
}
fun testJson() {
val str = """
{}
""".trimIndent()
println(json.decodeFromString<ChatItem>(str))
}
@@ -1,153 +0,0 @@
package chat.simplex.app
import android.app.Application
import android.net.LocalServerSocket
import android.util.Log
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.model.ChatController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
import java.util.concurrent.Semaphore
import kotlin.concurrent.thread
// ghc's rts
external fun initHS()
// android-support
external fun pipeStdOutToSocket(socketName: String) : Int
// SimpleX API
typealias ChatCtrl = Long
external fun chatInit(path: String): ChatCtrl
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
external fun chatRecvMsg(ctrl: ChatCtrl) : String
@DelicateCoroutinesApi
class SimplexApp: Application() {
private lateinit var controller: ChatController
lateinit var chatModel: ChatModel
override fun onCreate() {
super.onCreate()
val ctrl = chatInit(applicationContext.filesDir.toString())
controller = ChatController(ctrl, AlertManager())
chatModel = controller.chatModel
withApi {
val user = controller.apiGetActiveUser()
if (user != null) controller.startChat(user)
}
}
class AlertManager {
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
var presentAlert = mutableStateOf<Boolean>(false)
fun showAlert(alert: @Composable () -> Unit) {
Log.d("SIMPLEX", "AlertManager.showAlert")
alertView.value = alert
presentAlert.value = true
}
fun hideAlert() {
presentAlert.value = false
alertView.value = null
}
fun showAlertDialog(
title: String,
text: String? = null,
confirmText: String = "Ok",
onConfirm: (() -> Unit)? = null,
dismissText: String = "Cancel",
onDismiss: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
},
dismissButton = {
Button(onClick = {
onDismiss?.invoke()
hideAlert()
}) { Text(dismissText) }
}
)
}
}
fun showAlertMsg(
title: String, text: String? = null,
confirmText: String = "Ok", onConfirm: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
}
)
}
}
}
companion object {
init {
val socketName = "local.socket.address.listen.native.cmd2"
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d("SIMPLEX", "starting server")
val server = LocalServerSocket(socketName)
Log.d("SIMPLEX", "started server")
s.release()
val receiver = server.accept()
Log.d("SIMPLEX", "started receiver")
val logbuffer = FifoQueue<String>(500)
if (receiver != null) {
val inStream = receiver.inputStream
val inStreamReader = InputStreamReader(inStream)
val input = BufferedReader(inStreamReader)
while(true) {
val line = input.readLine() ?: break
Log.d("SIMPLEX (stdout/stderr)", line)
logbuffer.add(line)
}
}
}
System.loadLibrary("app-lib")
s.acquire()
pipeStdOutToSocket(socketName)
initHS()
}
}
}
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if(size > capacity) removeFirst()
return super.add(element)
}
}
@@ -1,667 +0,0 @@
package chat.simplex.app.model
import android.net.Uri
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.app.SimplexApp
import chat.simplex.app.ui.theme.*
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.datetime.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@DelicateCoroutinesApi
class ChatModel(val controller: ChatController, val alertManager: SimplexApp.AlertManager) {
var currentUser = mutableStateOf<User?>(null)
var userCreated = mutableStateOf<Boolean?>(null)
var chats = mutableStateListOf<Chat>()
var chatsLoaded = mutableStateOf<Boolean?>(null)
var chatId = mutableStateOf<String?>(null)
var chatItems = mutableStateListOf<ChatItem>()
var connReqInvitation: String? = null
var terminalItems = mutableStateListOf<TerminalItem>()
var userAddress = mutableStateOf<String?>(null)
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf<Uri?>(null)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value
if (user != null) {
currentUser.value = user.copy(profile = profile)
}
}
fun hasChat(id: String): Boolean = chats.firstOrNull { it.id == id } != null
fun getChat(id: String): Chat? = chats.firstOrNull { it.id == id }
private fun getChatIndex(id: String): Int = chats.indexOfFirst { it.id == id }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
fun updateChatInfo(cInfo: ChatInfo) {
val i = getChatIndex(cInfo.id)
if (i >= 0) chats[i] = chats[i].copy(chatInfo = cInfo)
}
fun updateContact(contact: Contact) {
val cInfo = ChatInfo.Direct(contact)
if (hasChat(contact.id)) {
updateChatInfo(cInfo)
} else {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf()))
}
}
fun updateNetworkStatus(contact: Contact, status: Chat.NetworkStatus) {
val i = getChatIndex(contact.id)
if (i >= 0) {
val chat = chats[i]
chats[i] = chat.copy(serverInfo = chat.serverInfo.copy(networkStatus = status))
}
}
fun replaceChat(id: String, chat: Chat) {
val i = getChatIndex(id)
if (i >= 0) {
chats[i] = chat
} else {
// invalid state, correcting
chats.add(index = 0, chat)
}
}
fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
// update previews
val i = getChatIndex(cInfo.id)
val chat: Chat
if (i >= 0) {
chat = chats[i]
chats[i] = chat.copy(
chatItems = arrayListOf(cItem),
chatStats =
if (cItem.meta.itemStatus is CIStatus.RcvNew) {
val minUnreadId = if(chat.chatStats.minUnreadItemId == 0L) cItem.id else chat.chatStats.minUnreadItemId
chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, minUnreadItemId = minUnreadId)
}
else
chat.chatStats
)
if (i > 0) {
popChat_(i)
}
} else {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
}
// add to current chat
if (chatId.value == cInfo.id) {
chatItems.add(cItem)
}
}
fun markChatItemsRead(cInfo: ChatInfo) {
val chatIdx = getChatIndex(cInfo.id)
// update current chat
if (chatId.value == cInfo.id) {
var i = 0
while (i < chatItems.count()) {
val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew) {
chatItems[i] = item.copy(meta=item.meta.copy(itemStatus = CIStatus.RcvRead()))
}
i += 1
}
val chat = chats[chatIdx]
chats[chatIdx] = chat.copy(
chatItems = chatItems,
chatStats = chat.chatStats.copy(unreadCount = 0, minUnreadItemId = chat.chatItems.last().id + 1)
)
}
}
//
// func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool {
// // update previews
// var res: Bool
// if let chat = getChat(cInfo.id) {
// if let pItem = chat.chatItems.last, pItem.id == cItem.id {
// chat.chatItems = [cItem]
// }
// res = false
// } else {
// addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
// res = true
// }
// // update current chat
// if chatId == cInfo.id {
// if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) {
// withAnimation(.default) {
// self.chatItems[i] = cItem
// }
// return false
// } else {
// withAnimation { chatItems.append(cItem) }
// return true
// }
// } else {
// return res
// }
// }
//
//
// func popChat(_ id: String) {
// if let i = getChatIndex(id) {
// popChat_(i)
// }
// }
//
private fun popChat_(i: Int) {
val chat = chats.removeAt(i)
chats.add(index = 0, chat)
}
fun removeChat(id: String) {
chats.removeAll { it.id == id }
}
}
enum class ChatType(val type: String) {
Direct("@"),
Group("#"),
ContactRequest("<@");
val chatTypeName: String get () =
when (this) {
Direct -> "contact"
Group -> "group"
ContactRequest -> "contact request"
}
}
@Serializable
data class User(
val userId: Long,
val userContactId: Long,
val localDisplayName: String,
val profile: Profile,
val activeUser: Boolean
): NamedChat {
override val displayName: String get() = profile.displayName
override val fullName: String get() = profile.fullName
companion object {
val sampleData = User(
userId = 1,
userContactId = 1,
localDisplayName = "alice",
profile = Profile.sampleData,
activeUser = true
)
}
}
typealias ChatId = String
interface NamedChat {
val displayName: String
val fullName: String
val chatViewName: String
get() = displayName + (if (fullName == "" || fullName == displayName) "" else " / $fullName")
}
interface SomeChat {
val chatType: ChatType
val localDisplayName: String
val id: ChatId
val apiId: Long
val ready: Boolean
val createdAt: Instant
}
@Serializable
data class Chat (
val chatInfo: ChatInfo,
val chatItems: List<ChatItem>,
val chatStats: ChatStats = ChatStats(),
val serverInfo: ServerInfo = ServerInfo(NetworkStatus.Unknown())
) {
val id: String get() = chatInfo.id
@Serializable
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0)
@Serializable
data class ServerInfo(val networkStatus: NetworkStatus)
@Serializable
sealed class NetworkStatus {
val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…"
val statusExplanation: String get() =
when {
this is Connected -> "You are connected to the server you use to receve messages from this contact."
this is Error -> "Trying to connect to the server you use to receve messages from this contact (error: $error)."
else -> "Trying to connect to the server you use to receve messages from this contact."
}
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
@Serializable @SerialName("connected") class Connected: NetworkStatus()
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
}
companion object {
val sampleData = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(ChatItem.getSampleData())
)
}
}
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
@Serializable @SerialName("direct")
class Direct(val contact: Contact): ChatInfo() {
override val chatType get() = ChatType.Direct
override val localDisplayName get() = contact.localDisplayName
override val id get() = contact.id
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val createdAt get() = contact.createdAt
override val displayName get() = contact.displayName
override val fullName get() = contact.fullName
companion object {
val sampleData = Direct(Contact.sampleData)
}
}
@Serializable @SerialName("group")
class Group(val groupInfo: GroupInfo): ChatInfo() {
override val chatType get() = ChatType.Group
override val localDisplayName get() = groupInfo.localDisplayName
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val createdAt get() = groupInfo.createdAt
override val displayName get() = groupInfo.displayName
override val fullName get() = groupInfo.fullName
companion object {
val sampleData = Group(GroupInfo.sampleData)
}
}
@Serializable @SerialName("contactRequest")
class ContactRequest(val contactRequest: UserContactRequest): ChatInfo() {
override val chatType get() = ChatType.ContactRequest
override val localDisplayName get() = contactRequest.localDisplayName
override val id get() = contactRequest.id
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val createdAt get() = contactRequest.createdAt
override val displayName get() = contactRequest.displayName
override val fullName get() = contactRequest.fullName
companion object {
val sampleData = ContactRequest(UserContactRequest.sampleData)
}
}
}
@Serializable
class Contact(
val contactId: Long,
override val localDisplayName: String,
val profile: Profile,
val activeConn: Connection,
val viaGroup: Long? = null,
override val createdAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
override val id get() = "@$contactId"
override val apiId get() = contactId
override val ready get() = activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready"
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
companion object {
val sampleData = Contact(
contactId = 1,
localDisplayName = "alice",
profile = Profile.sampleData,
activeConn = Connection.sampleData,
createdAt = Clock.System.now()
)
}
}
@Serializable
class ContactSubStatus(
val contact: Contact,
val contactError: ChatError? = null
)
@Serializable
class Connection(val connStatus: String) {
companion object {
val sampleData = Connection(connStatus = "ready")
}
}
@Serializable
class Profile(
val displayName: String,
val fullName: String
) {
companion object {
val sampleData = Profile(
displayName = "alice",
fullName = "Alice"
)
}
}
@Serializable
class GroupInfo (
val groupId: Long,
override val localDisplayName: String,
val groupProfile: GroupProfile,
override val createdAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.Group
override val id get() = "#$groupId"
override val apiId get() = groupId
override val ready get() = true
override val displayName get() = groupProfile.displayName
override val fullName get() = groupProfile.fullName
companion object {
val sampleData = GroupInfo(
groupId = 1,
localDisplayName = "team",
groupProfile = GroupProfile.sampleData,
createdAt = Clock.System.now()
)
}
}
@Serializable
class GroupProfile (
override val displayName: String,
override val fullName: String
): NamedChat {
companion object {
val sampleData = GroupProfile(
displayName = "team",
fullName = "My Team"
)
}
}
@Serializable
class GroupMember (
val groupMemberId: Long,
val memberId: String,
// var memberRole: GroupMemberRole
// var memberCategory: GroupMemberCategory
// var memberStatus: GroupMemberStatus
// var invitedBy: InvitedBy
val localDisplayName: String,
val memberProfile: Profile,
val memberContactId: Long?
// var activeConn: Connection?
) {
companion object {
val sampleData = GroupMember(
groupMemberId = 1,
memberId = "abcd",
localDisplayName = "alice",
memberProfile = Profile.sampleData,
memberContactId = 1
)
}
}
@Serializable
class MemberSubError (
val member: GroupMember,
val memberError: ChatError
)
@Serializable
class UserContactRequest (
val contactRequestId: Long,
override val localDisplayName: String,
val profile: Profile,
override val createdAt: Instant
): SomeChat, NamedChat {
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
override val apiId get() = contactRequestId
override val ready get() = true
override val displayName get() = profile.displayName
override val fullName get() = profile.fullName
companion object {
val sampleData = UserContactRequest(
contactRequestId = 1,
localDisplayName = "alice",
profile = Profile.sampleData,
createdAt = Clock.System.now()
)
}
}
@Serializable
class AChatItem (
val chatInfo: ChatInfo,
val chatItem: ChatItem
)
@Serializable
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
val content: CIContent,
val formattedText: List<FormattedText>? = null
) {
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
companion object {
fun getSampleData(
id: Long = 1,
dir: CIDirection = CIDirection.DirectSnd(),
ts: Instant = Clock.System.now(),
text: String = "hello\nthere",
status: CIStatus = CIStatus.SndNew()
) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text))
)
}
}
@Serializable
sealed class CIDirection {
abstract val sent: Boolean
@Serializable @SerialName("directSnd")
class DirectSnd: CIDirection() {
override val sent get() = true
}
@Serializable @SerialName("directRcv")
class DirectRcv: CIDirection() {
override val sent get() = false
}
@Serializable @SerialName("groupSnd")
class GroupSnd: CIDirection() {
override val sent get() = true
}
@Serializable @SerialName("groupRcv")
class GroupRcv(val groupMember: GroupMember): CIDirection() {
override val sent get() = false
}
}
@Serializable
data class CIMeta (
val itemId: Long,
val itemTs: Instant,
val itemText: String,
val itemStatus: CIStatus,
val createdAt: Instant
) {
val timestampText: String get() = getTimestampText(itemTs)
companion object {
fun getSample(id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew()): CIMeta =
CIMeta(
itemId = id,
itemTs = ts,
itemText = text,
itemStatus = status,
createdAt = ts
)
}
}
fun getTimestampText(t: Instant): String {
val tz = TimeZone.currentSystemDefault()
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
val time: LocalDateTime = t.toLocalDateTime(tz)
val recent = now.date == time.date ||
(now.date.minus(time.date).days == 1 && now.hour < 12 && time.hour >= 18 )
return if (recent) String.format("%02d:%02d", time.hour, time.minute)
else String.format("%02d/%02d", time.dayOfMonth, time.monthNumber)
}
@Serializable
sealed class CIStatus {
@Serializable @SerialName("sndNew")
class SndNew: CIStatus()
@Serializable @SerialName("sndSent")
class SndSent: CIStatus()
@Serializable @SerialName("sndErrorAuth")
class SndErrorAuth: CIStatus()
@Serializable @SerialName("sndError")
class SndError(val agentError: AgentErrorType): CIStatus()
@Serializable @SerialName("rcvNew")
class RcvNew: CIStatus()
@Serializable @SerialName("rcvRead")
class RcvRead: CIStatus()
}
@Serializable
sealed class CIContent {
abstract val text: String
@Serializable @SerialName("sndMsgContent")
class SndMsgContent(val msgContent: MsgContent): CIContent() {
override val text get() = msgContent.text
}
@Serializable @SerialName("rcvMsgContent")
class RcvMsgContent(val msgContent: MsgContent): CIContent() {
override val text get() = msgContent.text
}
@Serializable @SerialName("sndFileInvitation")
class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() {
override val text get() = "sending files is not supported yet"
}
@Serializable @SerialName("rcvFileInvitation")
class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() {
override val text get() = "receiving files is not supported yet"
}
}
@Serializable
sealed class MsgContent {
abstract val text: String
abstract val cmdString: String
@Serializable @SerialName("text")
class MCText(override val text: String): MsgContent() {
override val cmdString get() = "text $text"
}
}
@Serializable
class FormattedText(val text: String, val format: Format? = null) {
val link: String? = when (format) {
is Format.Uri -> text
is Format.Email -> "mailto:$text"
is Format.Phone -> "tel:$text"
else -> null
}
}
@Serializable
sealed class Format {
@Serializable @SerialName("bold") class Bold: Format()
@Serializable @SerialName("italic") class Italic: Format()
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
@Serializable @SerialName("snippet") class Snippet: Format()
@Serializable @SerialName("secret") class Secret: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
val style: SpanStyle @Composable get() = when (this) {
is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is Email -> linkStyle
is Phone -> linkStyle
}
companion object {
val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
}
}
@Serializable
enum class FormatColor(val color: String) {
red("red"),
green("green"),
blue("blue"),
yellow("yellow"),
cyan("cyan"),
magenta("magenta"),
black("black"),
white("white");
val uiColor: Color @Composable get() = when (this) {
red -> Color.Red
green -> Color.Green
blue -> Color.Blue
yellow -> Color.Yellow
cyan -> Color.Cyan
magenta -> Color.Magenta
black -> MaterialTheme.colors.onBackground
white -> MaterialTheme.colors.onBackground
}
}
@Serializable
class RcvFileTransfer
@@ -1,706 +0,0 @@
package chat.simplex.app.model
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.*
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlin.concurrent.thread
typealias ChatCtrl = Long
@DelicateCoroutinesApi
open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.AlertManager) {
var chatModel = ChatModel(this, alertManager)
suspend fun startChat(u: User) {
chatModel.currentUser = mutableStateOf(u)
chatModel.userCreated.value = true
Log.d("SIMPLEX (user)", u.toString())
try {
apiStartChat()
chatModel.userAddress.value = apiGetUserAddress()
chatModel.chats.addAll(apiGetChats())
chatModel.chatsLoaded.value = true
startReceiver()
Log.d("SIMPLEX", "started chat")
} catch(e: Error) {
Log.d("SIMPLEX", "failed starting chat $e")
throw e
}
}
fun startReceiver() {
thread(name="receiver") {
withApi { recvMspLoop() }
}
}
suspend fun sendCmd(cmd: CC): CR {
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
chatModel.terminalItems.add(TerminalItem.cmd(cmd))
val json = chatSendCmd(ctrl, c)
Log.d("SIMPLEX", "sendCmd: ${cmd.cmdType}")
val r = APIResponse.decodeStr(json)
Log.d("SIMPLEX", "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d("SIMPLEX", "sendCmd response json $json")
}
chatModel.terminalItems.add(TerminalItem.resp(r.resp))
r.resp
}
}
suspend fun recvMsg(): CR {
return withContext(Dispatchers.IO) {
val json = chatRecvMsg(ctrl)
val r = APIResponse.decodeStr(json).resp
Log.d("SIMPLEX", "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d("SIMPLEX", "chatRecvMsg json: $json")
r
}
}
suspend fun recvMspLoop() {
processReceivedMsg(recvMsg())
recvMspLoop()
}
suspend fun apiGetActiveUser(): User? {
val r = sendCmd(CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user
Log.d("SIMPLEX", "apiGetActiveUser: ${r.responseType} ${r.details}")
chatModel.userCreated.value = false
return null
}
suspend fun apiCreateActiveUser(p: Profile): User {
val r = sendCmd(CC.CreateActiveUser(p))
if (r is CR.ActiveUser) return r.user
Log.d("SIMPLEX", "apiCreateActiveUser: ${r.responseType} ${r.details}")
throw Error("user not created ${r.responseType} ${r.details}")
}
suspend fun apiStartChat() {
val r = sendCmd(CC.StartChat())
if (r is CR.ChatStarted ) return
throw Error("failed starting chat: ${r.responseType} ${r.details}")
}
suspend fun apiGetChats(): List<Chat> {
val r = sendCmd(CC.ApiGetChats())
if (r is CR.ApiChats ) return r.chats
throw Error("failed getting the list of chats: ${r.responseType} ${r.details}")
}
suspend fun apiGetChat(type: ChatType, id: Long): Chat? {
val r = sendCmd(CC.ApiGetChat(type, id))
if (r is CR.ApiChat ) return r.chat
Log.d("SIMPLEX", "apiGetChat bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, mc: MsgContent): AChatItem? {
val r = sendCmd(CC.ApiSendMessage(type, id, mc))
if (r is CR.NewChatItem ) return r.chatItem
Log.d("SIMPLEX", "apiSendMessage bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiAddContact(): String? {
val r = sendCmd(CC.AddContact())
if (r is CR.Invitation) return r.connReqInvitation
Log.d("SIMPLEX", "apiAddContact bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiConnect(connReq: String): Boolean {
val r = sendCmd(CC.Connect(connReq))
when {
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
r is CR.ContactAlreadyExists -> {
alertManager.showAlertMsg("Contact already exists",
"You are already connected to ${r.contact.displayName} via this link"
)
return false
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
alertManager.showAlertMsg("Invalid connection link",
"Please check that you used the correct link or ask your contact to send you another one."
)
return false
}
else -> {
apiErrorAlert("apiConnect", "Connection error", r)
return false
}
}
}
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
val r = sendCmd(CC.ApiDeleteChat(type, id))
when {
r is CR.ContactDeleted -> return true // TODO groups
r is CR.ChatCmdError -> {
val e = r.chatError
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
alertManager.showAlertMsg(
"Can't delete contact!",
"Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}"
)
return false
}
}
}
apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r)
return false
}
suspend fun apiUpdateProfile(profile: Profile): Profile? {
val r = sendCmd(CC.UpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
Log.d("SIMPLEX", "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiCreateUserAddress(): String? {
val r = sendCmd(CC.CreateMyAddress())
if (r is CR.UserContactLinkCreated) return r.connReqContact
Log.d("SIMPLEX", "apiCreateUserAddress bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiDeleteUserAddress(): Boolean {
val r = sendCmd(CC.DeleteMyAddress())
if (r is CR.UserContactLinkDeleted) return true
Log.d("SIMPLEX", "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiGetUserAddress(): String? {
val r = sendCmd(CC.ShowMyAddress())
if (r is CR.UserContactLink) return r.connReqContact
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
return null
}
Log.d("SIMPLEX", "apiGetUserAddress bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
if (r is CR.AcceptingContactRequest) return r.contact
Log.d("SIMPLEX", "apiAcceptContactRequest bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiRejectContactRequest(contactReqId: Long): Boolean {
val r = sendCmd(CC.ApiRejectContact(contactReqId))
if (r is CR.ContactRequestRejected) return true
Log.d("SIMPLEX", "apiRejectContactRequest bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
val r = sendCmd(CC.ApiChatRead(type, id, range))
if (r is CR.CmdOk) return true
Log.d("SIMPLEX", "apiChatRead bad response: ${r.responseType} ${r.details}")
return false
}
fun apiErrorAlert(method: String, title: String, r: CR) {
val errMsg = "${r.responseType}: ${r.details}"
Log.e("SIMPLEX", "$method bad response: $errMsg")
alertManager.showAlertMsg(title, errMsg)
}
fun processReceivedMsg(r: CR) {
chatModel.terminalItems.add(TerminalItem.resp(r))
when (r) {
is CR.ContactConnected -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
// NtfManager.shared.notifyContactConnected(contact)
}
is CR.ReceivedContactRequest -> {
val contactRequest = r.contactRequest
val cInfo = ChatInfo.ContactRequest(contactRequest)
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
// NtfManager.shared.notifyContactRequest(contactRequest)
}
is CR.ContactUpdated -> {
val cInfo = ChatInfo.Direct(r.toContact)
if (chatModel.hasChat(r.toContact.id)) {
chatModel.updateChatInfo(cInfo)
}
}
is CR.ContactSubscribed -> processContactSubscribed(r.contact)
is CR.ContactDisconnected -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
}
is CR.ContactSubError -> processContactSubError(r.contact, r.chatError)
is CR.ContactSubSummary -> {
for (sub in r.contactSubscriptions) {
val err = sub.contactError
if (err == null) processContactSubscribed(sub.contact)
else processContactSubError(sub.contact, sub.contactError)
}
}
is CR.NewChatItem -> {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
}
// case let .chatItemUpdated(aChatItem):
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
// if chatModel.upsertChatItem(cInfo, cItem) {
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
// }
// default:
// logger.debug("unsupported event: \(res.responseType)")
// }
}
}
fun processContactSubscribed(contact: Contact) {
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected())
}
fun processContactSubError(contact: Contact, chatError: ChatError) {
chatModel.updateContact(contact)
val e = chatError
val err: String =
if (e is ChatError.ChatErrorAgent) {
val a = e.agentError
when {
a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
else -> e.string
}
}
else e.string
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err))
}
}
// ChatCommand
sealed class CC {
class Console(val cmd: String): CC()
class ShowActiveUser: CC()
class CreateActiveUser(val profile: Profile): CC()
class StartChat: CC()
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
class UpdateProfile(val profile: Profile): CC()
class CreateMyAddress: CC()
class DeleteMyAddress: CC()
class ShowMyAddress: CC()
class ApiAcceptContact(val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
val cmdString: String get() = when (this) {
is Console -> cmd
is ShowActiveUser -> "/u"
is CreateActiveUser -> "/u ${profile.displayName} ${profile.fullName}"
is StartChat -> "/_start"
is ApiGetChats -> "/_get chats"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
is UpdateProfile -> "/profile ${profile.displayName} ${profile.fullName}"
is CreateMyAddress -> "/address"
is DeleteMyAddress -> "/delete_address"
is ShowMyAddress -> "/show_address"
is ApiAcceptContact -> "/_accept $contactReqId"
is ApiRejectContact -> "/_reject $contactReqId"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
}
val cmdType: String get() = when (this) {
is Console -> "console command"
is ShowActiveUser -> "showActiveUser"
is CreateActiveUser -> "createActiveUser"
is StartChat -> "startChat"
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
is UpdateProfile -> "updateProfile"
is CreateMyAddress -> "createMyAddress"
is DeleteMyAddress -> "deleteMyAddress"
is ShowMyAddress -> "showMyAddress"
is ApiAcceptContact -> "apiAcceptContact"
is ApiRejectContact -> "apiRejectContact"
is ApiChatRead -> "apiChatRead"
}
class ItemRange(val from: Long, val to: Long)
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
}
}
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
@Serializable
class APIResponse(val resp: CR, val corr: String? = null) {
companion object {
fun decodeStr(str: String): APIResponse {
return try {
json.decodeFromString(str)
} catch(e: Exception) {
try {
val data = json.parseToJsonElement(str).jsonObject
APIResponse(
resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)),
corr = data["corr"]?.toString()
)
} catch(e: Exception) {
APIResponse(CR.Invalid(str))
}
}
}
}
}
// ChatResponse
@Serializable
sealed class CR {
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR()
@Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR()
@Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR()
@Serializable @SerialName("contactConnected") class ContactConnected(val contact: Contact): CR()
@Serializable @SerialName("receivedContactRequest") class ReceivedContactRequest(val contactRequest: UserContactRequest): CR()
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected: CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val toContact: Contact): CR()
@Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR()
@Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List<ContactSubStatus>): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List<MemberSubError>): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
@Serializable @SerialName("chatItemUpdated") class ChatItemUpdated(val chatItem: AChatItem): CR()
@Serializable @SerialName("cmdOk") class CmdOk: CR()
@Serializable @SerialName("chatCmdError") class ChatCmdError(val chatError: ChatError): CR()
@Serializable @SerialName("chatError") class ChatRespError(val chatError: ChatError): CR()
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
val responseType: String get() = when(this) {
is ActiveUser -> "activeUser"
is ChatStarted -> "chatStarted"
is ApiChats -> "apiChats"
is ApiChat -> "apiChats"
is Invitation -> "invitation"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
is ContactAlreadyExists -> "contactAlreadyExists"
is ContactDeleted -> "contactDeleted"
is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated"
is UserContactLink -> "userContactLink"
is UserContactLinkCreated -> "userContactLinkCreated"
is UserContactLinkDeleted -> "userContactLinkDeleted"
is ContactConnected -> "contactConnected"
is ReceivedContactRequest -> "receivedContactRequest"
is AcceptingContactRequest -> "acceptingContactRequest"
is ContactRequestRejected -> "contactRequestRejected"
is ContactUpdated -> "contactUpdated"
is ContactSubscribed -> "contactSubscribed"
is ContactDisconnected -> "contactDisconnected"
is ContactSubError -> "contactSubError"
is ContactSubSummary -> "contactSubSummary"
is GroupSubscribed -> "groupSubscribed"
is MemberSubErrors -> "memberSubErrors"
is GroupEmpty -> "groupEmpty"
is UserContactLinkSubscribed -> "userContactLinkSubscribed"
is NewChatItem -> "newChatItem"
is ChatItemUpdated -> "chatItemUpdated"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
is Response -> "* $type"
is Invalid -> "* invalid json"
}
val details: String get() = when(this) {
is ActiveUser -> json.encodeToString(user)
is ChatStarted -> noDetails()
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is Invitation -> connReqInvitation
is SentConfirmation -> noDetails()
is SentInvitation -> noDetails()
is ContactAlreadyExists -> json.encodeToString(contact)
is ContactDeleted -> json.encodeToString(contact)
is UserProfileNoChange -> noDetails()
is UserProfileUpdated -> json.encodeToString(toProfile)
is UserContactLink -> connReqContact
is UserContactLinkCreated -> connReqContact
is UserContactLinkDeleted -> noDetails()
is ContactConnected -> json.encodeToString(contact)
is ReceivedContactRequest -> json.encodeToString(contactRequest)
is AcceptingContactRequest -> json.encodeToString(contact)
is ContactRequestRejected -> noDetails()
is ContactUpdated -> json.encodeToString(toContact)
is ContactSubscribed -> json.encodeToString(contact)
is ContactDisconnected -> json.encodeToString(contact)
is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}"
is ContactSubSummary -> json.encodeToString(contactSubscriptions)
is GroupSubscribed -> json.encodeToString(group)
is MemberSubErrors -> json.encodeToString(memberSubErrors)
is GroupEmpty -> json.encodeToString(group)
is UserContactLinkSubscribed -> noDetails()
is NewChatItem -> json.encodeToString(chatItem)
is ChatItemUpdated -> json.encodeToString(chatItem)
is CmdOk -> noDetails()
is ChatCmdError -> chatError.string
is ChatRespError -> chatError.string
is Response -> json
is Invalid -> str
}
fun noDetails(): String ="${responseType}: no details"
}
abstract class TerminalItem {
abstract val id: Long
val date: Instant = Clock.System.now()
abstract val label: String
abstract val details: String
class Cmd(override val id: Long, val cmd: CC): TerminalItem() {
override val label get() = "> ${cmd.cmdString}"
override val details get() = cmd.cmdString
}
class Resp(override val id: Long, val resp: CR): TerminalItem() {
override val label get() = "< ${resp.responseType}"
override val details get() = resp.details
}
companion object {
val sampleData = listOf(
Cmd(0, CC.ShowActiveUser()),
Resp(1, CR.ActiveUser(User.sampleData))
)
fun cmd(c: CC) = Cmd(System.currentTimeMillis(), c)
fun resp(r: CR) = Resp(System.currentTimeMillis(), r)
}
}
@Serializable
sealed class ChatError {
val string: String get() = when (this) {
is ChatErrorChat -> "chat ${errorType.string}"
is ChatErrorAgent -> "agent ${agentError.string}"
is ChatErrorStore -> "store ${storeError.string}"
}
@Serializable @SerialName("error") class ChatErrorChat(val errorType: ChatErrorType): ChatError()
@Serializable @SerialName("errorAgent") class ChatErrorAgent(val agentError: AgentErrorType): ChatError()
@Serializable @SerialName("errorStore") class ChatErrorStore(val storeError: StoreError): ChatError()
}
@Serializable
sealed class ChatErrorType {
val string: String get() = when (this) {
is InvalidConnReq -> "invalidConnReq"
is ContactGroups -> "groupNames $groupNames"
}
@Serializable @SerialName("invalidConnReq") class InvalidConnReq: ChatErrorType()
@Serializable @SerialName("contactGroups") class ContactGroups(val contact: Contact, val groupNames: List<String>): ChatErrorType()
}
@Serializable
sealed class StoreError {
val string: String get() = when (this) {
is UserContactLinkNotFound -> "userContactLinkNotFound"
}
@Serializable @SerialName("userContactLinkNotFound") class UserContactLinkNotFound: StoreError()
}
@Serializable
sealed class AgentErrorType {
val string: String get() = when (this) {
is CMD -> "CMD ${cmdErr.string}"
is CONN -> "CONN ${connErr.string}"
is SMP -> "SMP ${smpErr.string}"
is BROKER -> "BROKER ${brokerErr.string}"
is AGENT -> "AGENT ${agentErr.string}"
is INTERNAL -> "INTERNAL $internalErr"
}
@Serializable @SerialName("CMD") class CMD(val cmdErr: CommandErrorType): AgentErrorType()
@Serializable @SerialName("CONN") class CONN(val connErr: ConnectionErrorType): AgentErrorType()
@Serializable @SerialName("SMP") class SMP(val smpErr: SMPErrorType): AgentErrorType()
@Serializable @SerialName("BROKER") class BROKER(val brokerErr: BrokerErrorType): AgentErrorType()
@Serializable @SerialName("AGENT") class AGENT(val agentErr: SMPAgentError): AgentErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL(val internalErr: String): AgentErrorType()
}
@Serializable
sealed class CommandErrorType {
val string: String get() = when (this) {
is PROHIBITED -> "PROHIBITED"
is SYNTAX -> "SYNTAX"
is NO_CONN -> "NO_CONN"
is SIZE -> "SIZE"
is LARGE -> "LARGE"
}
@Serializable @SerialName("PROHIBITED") class PROHIBITED: CommandErrorType()
@Serializable @SerialName("SYNTAX") class SYNTAX: CommandErrorType()
@Serializable @SerialName("NO_CONN") class NO_CONN: CommandErrorType()
@Serializable @SerialName("SIZE") class SIZE: CommandErrorType()
@Serializable @SerialName("LARGE") class LARGE: CommandErrorType()
}
@Serializable
sealed class ConnectionErrorType {
val string: String get() = when (this) {
is NOT_FOUND -> "NOT_FOUND"
is DUPLICATE -> "DUPLICATE"
is SIMPLEX -> "SIMPLEX"
is NOT_ACCEPTED -> "NOT_ACCEPTED"
is NOT_AVAILABLE -> "NOT_AVAILABLE"
}
@Serializable @SerialName("NOT_FOUND") class NOT_FOUND: ConnectionErrorType()
@Serializable @SerialName("DUPLICATE") class DUPLICATE: ConnectionErrorType()
@Serializable @SerialName("SIMPLEX") class SIMPLEX: ConnectionErrorType()
@Serializable @SerialName("NOT_ACCEPTED") class NOT_ACCEPTED: ConnectionErrorType()
@Serializable @SerialName("NOT_AVAILABLE") class NOT_AVAILABLE: ConnectionErrorType()
}
@Serializable
sealed class BrokerErrorType {
val string: String get() = when (this) {
is RESPONSE -> "RESPONSE ${smpErr.string}"
is UNEXPECTED -> "UNEXPECTED"
is NETWORK -> "NETWORK"
is TRANSPORT -> "TRANSPORT ${transportErr.string}"
is TIMEOUT -> "TIMEOUT"
}
@Serializable @SerialName("RESPONSE") class RESPONSE(val smpErr: SMPErrorType): BrokerErrorType()
@Serializable @SerialName("UNEXPECTED") class UNEXPECTED: BrokerErrorType()
@Serializable @SerialName("NETWORK") class NETWORK: BrokerErrorType()
@Serializable @SerialName("TRANSPORT") class TRANSPORT(val transportErr: SMPTransportError): BrokerErrorType()
@Serializable @SerialName("TIMEOUT") class TIMEOUT: BrokerErrorType()
}
@Serializable
sealed class SMPErrorType {
val string: String get() = when (this) {
is BLOCK -> "BLOCK"
is SESSION -> "SESSION"
is CMD -> "CMD ${cmdErr.string}"
is AUTH -> "AUTH"
is QUOTA -> "QUOTA"
is NO_MSG -> "NO_MSG"
is LARGE_MSG -> "LARGE_MSG"
is INTERNAL -> "INTERNAL"
}
@Serializable @SerialName("BLOCK") class BLOCK: SMPErrorType()
@Serializable @SerialName("SESSION") class SESSION: SMPErrorType()
@Serializable @SerialName("CMD") class CMD(val cmdErr: SMPCommandError): SMPErrorType()
@Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
@Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
@Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType()
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
@Serializable @SerialName("INTERNAL") class INTERNAL: SMPErrorType()
}
@Serializable
sealed class SMPCommandError {
val string: String get() = when (this) {
is UNKNOWN -> "UNKNOWN"
is SYNTAX -> "SYNTAX"
is NO_AUTH -> "NO_AUTH"
is HAS_AUTH -> "HAS_AUTH"
is NO_QUEUE -> "NO_QUEUE"
}
@Serializable @SerialName("UNKNOWN") class UNKNOWN: SMPCommandError()
@Serializable @SerialName("SYNTAX") class SYNTAX: SMPCommandError()
@Serializable @SerialName("NO_AUTH") class NO_AUTH: SMPCommandError()
@Serializable @SerialName("HAS_AUTH") class HAS_AUTH: SMPCommandError()
@Serializable @SerialName("NO_QUEUE") class NO_QUEUE: SMPCommandError()
}
@Serializable
sealed class SMPTransportError {
val string: String get() = when (this) {
is BadBlock -> "badBlock"
is LargeMsg -> "largeMsg"
is BadSession -> "badSession"
is Handshake -> "handshake ${handshakeErr.string}"
}
@Serializable @SerialName("badBlock") class BadBlock: SMPTransportError()
@Serializable @SerialName("largeMsg") class LargeMsg: SMPTransportError()
@Serializable @SerialName("badSession") class BadSession: SMPTransportError()
@Serializable @SerialName("handshake") class Handshake(val handshakeErr: SMPHandshakeError): SMPTransportError()
}
@Serializable
sealed class SMPHandshakeError {
val string: String get() = when (this) {
is PARSE -> "PARSE"
is VERSION -> "VERSION"
is IDENTITY -> "IDENTITY"
}
@Serializable @SerialName("PARSE") class PARSE: SMPHandshakeError()
@Serializable @SerialName("VERSION") class VERSION: SMPHandshakeError()
@Serializable @SerialName("IDENTITY") class IDENTITY: SMPHandshakeError()
}
@Serializable
sealed class SMPAgentError {
val string: String get() = when (this) {
is A_MESSAGE -> "A_MESSAGE"
is A_PROHIBITED -> "A_PROHIBITED"
is A_VERSION -> "A_VERSION"
is A_ENCRYPTION -> "A_ENCRYPTION"
}
@Serializable @SerialName("A_MESSAGE") class A_MESSAGE: SMPAgentError()
@Serializable @SerialName("A_PROHIBITED") class A_PROHIBITED: SMPAgentError()
@Serializable @SerialName("A_VERSION") class A_VERSION: SMPAgentError()
@Serializable @SerialName("A_ENCRYPTION") class A_ENCRYPTION: SMPAgentError()
}
@@ -1,15 +0,0 @@
package chat.simplex.app.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
val Gray = Color(0x22222222)
val SimplexBlue = Color(0, 136, 255, 255)
val SimplexGreen = Color(98, 196, 103, 255)
val SecretColor = Color(0x40808080)
val LightGray = Color(241, 242, 246, 255)
val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(134, 135, 139, 255)
@@ -1,11 +0,0 @@
package chat.simplex.app.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)
@@ -1,48 +0,0 @@
package chat.simplex.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.*
import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors(
primary = SimplexBlue,
primaryVariant = SimplexGreen,
secondary = DarkGray,
// background = Color.Black,
// surface = Color.Black,
// background = Color(0xFF121212),
// surface = Color(0xFF121212),
// error = Color(0xFFCF6679),
// onPrimary = Color.Black,
// onSecondary = Color.Black,
// onBackground = Color.White,
// onSurface = Color.White,
// onError: Color = Color.Black,
)
private val LightColorPalette = lightColors(
primary = SimplexBlue,
primaryVariant = SimplexGreen,
secondary = LightGray,
// background = Color.White,
// surface = Color.White
// onPrimary = Color.White,
// onSecondary = Color.Black,
// onBackground = Color.Black,
// onSurface = Color.Black,
)
@Composable
fun SimpleXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
@@ -1,46 +0,0 @@
package chat.simplex.app.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
h1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 32.sp,
),
h2 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 24.sp
),
h3 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 20.sp
),
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 18.sp
),
body2 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 20.sp
)
)
@@ -1,29 +0,0 @@
package chat.simplex.app.views
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
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.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@Composable
fun SplashView() {
Box(modifier = Modifier
.fillMaxSize()
.background(color = Color.White)
) {
// Image(
// painter = painterResource(R.drawable.logo),
// contentDescription = "Simplex Icon",
// modifier = Modifier
// .height(230.dp)
// .align(Alignment.Center)
// )
}
}
@@ -1,117 +0,0 @@
package chat.simplex.app.views
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.SendMsgView
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
@DelicateCoroutinesApi
@Composable
fun TerminalView(chatModel: ChatModel, nav: NavController) {
TerminalLayout(chatModel.terminalItems, nav::popBackStack, nav::navigate) { cmd ->
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(cmd))
// hide "in progress"
}
}
}
@Composable
fun TerminalLayout(terminalItems: List<TerminalItem> , close: () -> Unit, navigate: (String) -> Unit,
sendCommand: (String) -> Unit) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
bottomBar = { SendMsgView(sendCommand) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
TerminalLog(terminalItems, navigate)
}
}
}
}
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>, navigate: (String) -> Unit) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(terminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable { navigate("details/${item.id}") })
}
val len = terminalItems.count()
if (len > 1) {
scope.launch {
listState.animateScrollToItem(len - 1)
}
}
}
}
@Composable
fun DetailView(identifier: Long, terminalItems: List<TerminalItem>, nav: NavController){
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Column {
CloseSheetBar(nav::popBackStack)
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "")
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewTerminalLayout() {
SimpleXTheme {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
close = {},
navigate = {},
sendCommand = {}
)
}
}
@@ -1,163 +0,0 @@
package chat.simplex.app.views
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun WelcomeView(chatModel: ChatModel, routeHome: () -> Unit) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
.padding(12.dp)
) {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = "Simplex Logo",
modifier = Modifier.padding(vertical = 15.dp)
)
Text(
"You control your chat!",
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground
)
Text(
"The messaging and application platform protecting your privacy and security.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(8.dp))
Text(
"We don't store any of your contacts or messages (once delivered) on the servers.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(24.dp))
CreateProfilePanel(chatModel, routeHome)
}
}
}
}
fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null
}
@DelicateCoroutinesApi
@Composable
fun CreateProfilePanel(chatModel: ChatModel, routeHome: () -> Unit) {
var displayName by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
Column(
modifier=Modifier.fillMaxSize()
) {
Text(
"Create profile",
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(vertical = 5.dp)
)
Text(
"Your profile is stored on your device and shared only with your contacts.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(10.dp))
Text(
"Display Name",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 3.dp)
)
BasicTextField(
value = displayName,
onValueChange = { displayName = it },
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(5.dp))
.padding(8.dp)
.navigationBarsWithImePadding(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
"Full Name (Optional)",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 5.dp)
)
BasicTextField(
value = fullName,
onValueChange = { fullName = it },
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(3.dp))
.padding(8.dp)
.navigationBarsWithImePadding(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
Spacer(Modifier.height(20.dp))
Button(onClick = {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName)
)
chatModel.controller.startChat(user)
routeHome()
}
},
enabled = displayName.isNotEmpty()
) { Text("Create")}
}
}
@@ -1,136 +0,0 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Delete
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun ChatInfoView(chatModel: ChatModel, nav: NavController) {
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
if (chat != null) {
ChatInfoLayout(chat,
close = { nav.popBackStack() },
deleteContact = {
chatModel.alertManager.showAlertMsg(
title = "Delete contact?",
text = "Contact and all messages will be deleted - this cannot be undone!",
confirmText = "Delete",
onConfirm = {
val cInfo = chat.chatInfo
withApi {
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
if (r) {
chatModel.removeChat(cInfo.id)
nav.navigate(Pages.ChatList.route)
}
}
}
)
}
)
}
}
@Composable
fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
Column(Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Spacer(Modifier.size(48.dp))
ChatInfoImage(chat, size = 192.dp)
val cInfo = chat.chatInfo
Text(
cInfo.displayName, style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(top = 32.dp).padding(bottom = 8.dp)
)
Text(
cInfo.fullName, style = MaterialTheme.typography.h2,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 16.dp)
)
if (cInfo is ChatInfo.Direct) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(Modifier.padding(horizontal = 32.dp)) {
ServerImage(chat)
Text(
chat.serverInfo.networkStatus.statusString,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(start = 8.dp)
)
}
Text(
chat.serverInfo.networkStatus.statusExplanation,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp).padding(horizontal = 16.dp)
)
}
Spacer(Modifier.weight(1F))
Box(Modifier.padding(24.dp)) {
SimpleButton(
"Delete contact", icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteContact
)
}
}
}
}
@Composable
fun ServerImage(chat: Chat) {
val status = chat.serverInfo.networkStatus
when {
status is Chat.NetworkStatus.Connected ->
Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant)
status is Chat.NetworkStatus.Disconnected ->
Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight)
status is Chat.NetworkStatus.Error ->
Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight)
else ->
Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight)
}
}
@Preview
@Composable
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(),
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
),
close = {}, deleteContact = {}
)
}
}
@@ -1,200 +0,0 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.*
import kotlinx.coroutines.*
import kotlinx.datetime.Clock
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@Composable
fun ChatView(chatModel: ChatModel, nav: NavController) {
if (chatModel.chatId.value != null && chatModel.chats.count() > 0) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat != null) {
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
delay(1000L)
if (chat.chatItems.count() > 0) {
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
)
}
}
}
ChatLayout(chat, chatModel.chatItems,
back = { nav.popBackStack() },
info = { nav.navigate(Pages.ChatInfo.route) },
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val newItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
mc = MsgContent.MCText(msg)
)
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
}
)
}
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatLayout(
chat: Chat, chatItems: List<ChatItem>,
back: () -> Unit,
info: () -> Unit,
sendMessage: (String) -> Unit
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = { SendMsgView(sendMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
ChatItemsList(chatItems)
}
}
}
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
Box(Modifier.height(60.dp).padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart
) {
IconButton(onClick = back) {
Icon(
Icons.Outlined.ArrowBack,
"Back",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Row(
Modifier
.padding(horizontal = 68.dp)
.fillMaxWidth()
.clickable(onClick = info),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val cInfo = chat.chatInfo
ChatInfoImage(chat, size = 40.dp)
Column(Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(cInfo.displayName, fontWeight = FontWeight.Bold,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
Text(cInfo.fullName,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatItemsList(chatItems: List<ChatItem>) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
LazyColumn(state = listState) {
items(chatItems) { cItem ->
ChatItemView(cItem, uriHandler)
}
val len = chatItems.count()
if (len > 1) {
scope.launch {
listState.animateScrollToItem(len - 1)
}
}
}
}
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatLayout() {
SimpleXTheme {
val chatItems = listOf(
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
2, CIDirection.DirectRcv(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
3, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
4, CIDirection.DirectSnd(), Clock.System.now(), "hello"
),
ChatItem.getSampleData(
5, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
ChatLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
chatItems = chatItems,
back = {},
info = {},
sendMessage = {}
)
}
}
@@ -1,90 +0,0 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowUpward
import androidx.compose.runtime.*
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.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun SendMsgView(sendMessage: (String) -> Unit) {
var cmd by remember { mutableStateOf("") }
BasicTextField(
value = cmd,
onValueChange = { cmd = it },
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(8.dp),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Bottom
) {
Box(
Modifier
.weight(1f)
.padding(horizontal = 12.dp)
.padding(top = 5.dp)
.padding(bottom = 7.dp)
) {
innerTextField()
}
val color = if (cmd.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
Icon(
Icons.Outlined.ArrowUpward,
"Send Message",
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.clip(CircleShape)
.background(color)
.clickable {
if (cmd.isNotEmpty()) {
sendMessage(cmd)
cmd = ""
}
}
)
}
}
}
)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSendMsgView() {
SimpleXTheme {
SendMsgView(
sendMessage = { msg -> println(msg) }
)
}
}
@@ -1,30 +0,0 @@
package chat.simplex.app.views.chat.item
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
import kotlinx.datetime.Clock
@Composable
fun CIMetaView(chatItem: ChatItem) {
Text(
chatItem.timestampText,
color = HighOrLowlight,
fontSize = 14.sp
)
}
@Preview
@Composable
fun PreviewCIMetaView() {
CIMetaView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
@@ -1,47 +0,0 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
@ExperimentalTextApi
@Composable
fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
Box(
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth()
.padding(
start = if (sent) 60.dp else 16.dp,
end = if (sent) 16.dp else 60.dp,
),
contentAlignment = alignment,
) {
TextItemView(chatItem, uriHandler)
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@@ -1,153 +0,0 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
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.platform.UriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.LightGray
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
// TODO move to theme
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x1EB1B0B5)
@ExperimentalTextApi
@Composable
fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight
) {
Box(
modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp)
) {
Box(contentAlignment = Alignment.BottomEnd) {
MarkdownText(chatItem, uriHandler = uriHandler, groupMemberBold = true)
CIMetaView(chatItem)
}
}
}
}
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
if (chatItem.chatDir is CIDirection.GroupRcv) {
val name = chatItem.chatDir.groupMember.memberProfile.displayName
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
else b.append(name)
b.append(": ")
}
}
@ExperimentalTextApi
@Composable
fun MarkdownText (
chatItem: ChatItem,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
groupMemberBold: Boolean = false,
modifier: Modifier = Modifier
) {
if (chatItem.formattedText == null) {
val annotatedText = buildAnnotatedString {
appendGroupMember(this, chatItem, groupMemberBold)
append(chatItem.content.text)
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
}
SelectionContainer {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
} else {
val annotatedText = buildAnnotatedString {
appendGroupMember(this, chatItem, groupMemberBold)
for (ft in chatItem.formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
if (link != null) {
withAnnotation(tag = "URL", annotation = link) {
withStyle(ft.format.style) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
}
}
}
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
}
if (uriHandler != null) {
SelectionContainer {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
}
)
}
} else {
SelectionContainer {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
}
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewSnd() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewRcv() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewLong() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
)
)
}
}
@@ -1,170 +0,0 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.usersettings.simplexTeamUri
@Composable
fun ChatHelpView(addContact: () -> Unit, doAddContact: Boolean) {
Column(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
val uriHandler = LocalUriHandler.current
Text(
"Thank you for installing SimpleX Chat!",
color = MaterialTheme.colors.onBackground
)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("You can ")
}
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("connect to SimpleX team")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(".")
}
},
modifier = Modifier
.clickable(onClick = { uriHandler.openUri(simplexTeamUri) })
)
Column(
Modifier.padding(top = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"To start a new chat",
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.h2
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Tap button",
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
modifier = if (doAddContact) Modifier.clickable(onClick = addContact) else Modifier,
tint = MaterialTheme.colors.onBackground,
)
Text(
"above, then:",
color = MaterialTheme.colors.onBackground
)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("Add new contact")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(": to create your one-time QR Code for your contact.")
}
}
)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("Scan QR code")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(": to connect to your contact who shows QR code to you.")
}
}
)
}
Column(
Modifier.padding(top = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"To connect via link",
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.h2
)
Text(
"If you received SimpleX Chat invitation link you can open it in your browser:",
color = MaterialTheme.colors.onBackground
)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
}
withStyle(
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
) {
append("Scan QR code")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(".")
}
}
)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("\uD83D\uDCF1 mobile: tap ")
}
withStyle(
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
) {
append("Open in mobile app")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(", then tap ")
}
withStyle(
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
) {
append("Connect")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(" in the app.")
}
}
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatHelpLayout() {
SimpleXTheme {
ChatHelpView({}, false)
}
}
@@ -1,193 +0,0 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.datetime.Clock
@ExperimentalTextApi
@Composable
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel, nav: NavController) {
ChatListNavLink(
chat = chat,
action = {
when (chat.chatInfo) {
is ChatInfo.Direct -> chatNavLink(chat, chatModel, nav)
is ChatInfo.Group -> chatNavLink(chat, chatModel, nav)
is ChatInfo.ContactRequest -> contactRequestNavLink(chat.chatInfo, chatModel, nav)
}
}
)
}
@DelicateCoroutinesApi
fun chatNavLink(chatPreview: Chat, chatModel: ChatModel, navController: NavController) {
withApi {
val chatInfo = chatPreview.chatInfo
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
if (chat != null) {
chatModel.chatId.value = chatInfo.id
chatModel.chatItems = chat.chatItems.toMutableStateList()
navController.navigate(Pages.Chat.route)
} else {
// TODO show error? or will apiGetChat show it
}
}
}
@DelicateCoroutinesApi
fun contactRequestNavLink(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, navController: NavController) {
chatModel.alertManager.showAlertDialog(
title = "Accept connection request?",
text = "If you choose to reject sender will NOT be notified",
confirmText = "Accept",
onConfirm = {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
if (contact != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
}
}
},
dismissText = "Reject",
onDismiss = {
withApi {
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
chatModel.removeChat(contactRequest.id)
}
}
)
}
@ExperimentalTextApi
@Composable
fun ChatListNavLink(chat: Chat, action: () -> Unit) {
ChatListNavLinkLayout(
content = {
when (chat.chatInfo) {
is ChatInfo.Direct -> ChatPreviewView(chat)
is ChatInfo.Group -> ChatPreviewView(chat)
is ChatInfo.ContactRequest -> ContactRequestView(chat)
}
},
action = action
)
}
@Composable
fun ChatListNavLinkLayout(content: (@Composable () -> Unit), action: () -> Unit) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = action)
.height(88.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
verticalAlignment = Alignment.Top,
// TODO?
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.SpaceEvenly
) {
content.invoke()
}
}
Divider(Modifier.padding(horizontal = 8.dp))
}
@ExperimentalTextApi
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkDirect() {
SimpleXTheme {
ChatListNavLink(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
)
),
chatStats = Chat.ChatStats()
),
action = {}
)
}
}
@ExperimentalTextApi
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkGroup() {
SimpleXTheme {
ChatListNavLink(
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = listOf(
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
)
),
chatStats = Chat.ChatStats()
),
action = {}
)
}
}
@ExperimentalTextApi
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLink(
chat = Chat(
chatInfo = ChatInfo.ContactRequest.sampleData,
chatItems = listOf(),
chatStats = Chat.ChatStats()
),
action = {}
)
}
}
@@ -1,205 +0,0 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.usersettings.SettingsView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.*
@ExperimentalMaterialApi
class ScaffoldController(val scope: CoroutineScope) {
lateinit var state: BottomSheetScaffoldState
val expanded = mutableStateOf(false)
fun expand() {
expanded.value = true
scope.launch { state.bottomSheetState.expand() }
}
fun collapse() {
expanded.value = false
scope.launch { state.bottomSheetState.collapse() }
}
fun toggleSheet() {
if (state.bottomSheetState.isExpanded ?: false) collapse() else expand()
}
fun toggleDrawer() = scope.launch {
state.drawerState.apply { if (isClosed) open() else close() }
}
}
@ExperimentalMaterialApi
@Composable
fun scaffoldController(): ScaffoldController {
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
val bottomSheetState = rememberBottomSheetState(
BottomSheetValue.Collapsed,
confirmStateChange = {
ctrl.expanded.value = it == BottomSheetValue.Expanded
true
}
)
ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
return ctrl
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun ChatListView(chatModel: ChatModel, nav: NavController) {
val scaffoldCtrl = scaffoldController()
BottomSheetScaffold(
scaffoldState = scaffoldCtrl.state,
drawerContent = { SettingsView(chatModel, nav) },
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl, nav) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
) {
Box {
Column(
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl)
when (chatModel.chatsLoaded.value) {
true -> if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, nav)
} else {
val user = chatModel.currentUser.value
Help(scaffoldCtrl, displayName = user?.profile?.displayName)
}
else -> ChatList(chatModel, nav)
}
}
if (scaffoldCtrl.expanded.value) {
Surface(
Modifier
.fillMaxSize()
.clickable { scaffoldCtrl.collapse() },
color = Color.Black.copy(alpha = 0.12F)
) {}
}
}
}
}
@ExperimentalMaterialApi
@Composable
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(
text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView({ scaffoldCtrl.toggleSheet() }, true)
Row(
Modifier.padding(top = 30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"This text is available in settings",
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.Settings,
"Settings",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
)
}
}
}
@ExperimentalMaterialApi
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.height(60.dp)
) {
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
Icons.Outlined.Settings,
"Settings",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
Text(
"Your chats",
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(5.dp)
)
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@Composable
fun ChatList(chatModel: ChatModel, navController: NavController) {
Divider(Modifier.padding(horizontal = 8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chatModel.chats) { chat ->
ChatListNavLinkView(chat, chatModel, navController)
}
}
}
//@Preview
//@Composable
//fun PreviewChatListView() {
// SimpleXTheme {
// ChatListView(
// chats = listOf(
// Chat()
// ),
//
// )
// }
//}
@@ -1,93 +0,0 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.Chat
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
@ExperimentalTextApi
@Composable
fun ChatPreviewView(chat: Chat) {
Row {
ChatInfoImage(chat, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
chat.chatInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
)
if (chat.chatItems.count() > 0) {
MarkdownText(
chat.chatItems.last(),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
) {
Text(
ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
val n = chat.chatStats.unreadCount
if (n > 0) {
Text(
if (n < 1000) "$n" else "${n / 1000}k",
color = MaterialTheme.colors.onPrimary,
fontSize = 14.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.align(Alignment.End)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
}
}
}
}
@ExperimentalTextApi
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData)
}
}
@@ -1,52 +0,0 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.Chat
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.ChatInfoImage
@Composable
fun ContactRequestView(chat: Chat) {
Row {
ChatInfoImage(chat, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
chat.chatInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
Text(
"wants to connect to you!",
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
val ts = getTimestampText(chat.chatInfo.createdAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
) {
Text(
ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
}
}
}
@@ -1,44 +0,0 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.Chat
import chat.simplex.app.model.ChatInfo
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun ChatInfoImage(chat: Chat, size: Dp) {
val icon =
if (chat.chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
else Icons.Filled.AccountCircle
Box(Modifier.size(size)) {
Icon(icon,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.secondary,
modifier = Modifier.fillMaxSize()
)
}
}
@Preview
@Composable
fun PreviewChatInfoImage() {
SimpleXTheme {
ChatInfoImage(
chat = Chat(chatInfo = ChatInfo.Direct.sampleData, chatItems = arrayListOf()),
size = 55.dp
)
}
}
@@ -1,46 +0,0 @@
package chat.simplex.app.views.helpers
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun CloseSheetBar(close: () -> Unit) {
Row (
Modifier
.fillMaxWidth()
.height(60.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = close) {
Icon(
Icons.Outlined.Close,
"Close button",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewCloseSheetBar() {
SimpleXTheme {
CloseSheetBar(close = {})
}
}
@@ -1,17 +0,0 @@
package chat.simplex.app.views.helpers
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
fun Modifier.badgeLayout() =
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
// based on the expectation of only one line of text
val minPadding = placeable.height / 4
val width = maxOf(placeable.width + minPadding, placeable.height)
layout(width, placeable.height) {
placeable.place((width - placeable.width) / 2, 0)
}
}
@@ -1,14 +0,0 @@
package chat.simplex.app.views.helpers
import android.content.Context
import android.content.Intent
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
cxt.startActivity(shareIntent)
}
@@ -1,38 +0,0 @@
package chat.simplex.app.ui.theme
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
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.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SimpleButton(text: String, icon: ImageVector,
color: Color = MaterialTheme.colors.primary,
click: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { click() }
) {
Icon(icon, text, tint = color,
modifier = Modifier.padding(horizontal = 10.dp)
)
Text(text, style = MaterialTheme.typography.caption, color = color)
}
}
@Preview
@Composable
fun PreviewCloseSheetBar() {
SimpleXTheme {
SimpleButton(text = "Share", icon = Icons.Outlined.Share, click = {})
}
}
@@ -1,7 +0,0 @@
package chat.simplex.app.views.helpers
import kotlinx.coroutines.*
@DelicateCoroutinesApi
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch { withContext(Dispatchers.Main, action) }
@@ -1,99 +0,0 @@
package chat.simplex.app.views.newchat
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Share
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.shareText
@Composable
fun AddContactView(chatModel: ChatModel, nav: NavController) {
val connReq = chatModel.connReqInvitation
if (connReq != null) {
val cxt = LocalContext.current
AddContactLayout(
connReq = connReq,
close = { nav.popBackStack() },
share = { shareText(cxt, connReq) }
)
}
}
@Composable
fun AddContactLayout(connReq: String, close: () -> Unit, share: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloseSheetBar(close)
Text(
"Add contact",
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Show QR code to your contact\nto scan from the app",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground
)
QRCode(connReq)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("If you cannot meet in person, you can ")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(", or you can share the invitation link via any other channel.")
}
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
)
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewAddContactView() {
SimpleXTheme {
AddContactLayout(
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
close = {},
share = {}
)
}
}
@@ -1,139 +0,0 @@
package chat.simplex.app.views.newchat
import android.content.res.Configuration
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun ConnectContactView(chatModel: ChatModel, nav: NavController) {
ConnectContactLayout(
qrCodeScanner = {
QRCodeScanner { connReqUri ->
try {
val uri = Uri.parse(connReqUri)
withUriAction(chatModel, uri) { action ->
connectViaUri(chatModel, action, uri)
}
} catch (e: RuntimeException) {
chatModel.alertManager.showAlertMsg(
title = "Invalid QR code",
text = "This QR code is not a link!"
)
}
nav.popBackStack()
}
},
close = { nav.popBackStack() }
)
}
@DelicateCoroutinesApi
fun withUriAction(
chatModel: ChatModel, uri: Uri,
run: suspend (String) -> Unit
) {
val action = uri.path?.drop(1)
if (action == "contact" || action == "invitation") {
withApi { run(action) }
} else {
chatModel.alertManager.showAlertMsg(
title = "Invalid link!",
text = "This link is not a valid connection link!"
)
}
}
suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
val r = chatModel.controller.apiConnect(uri.toString())
if (r) {
val whenConnected =
if (action == "contact") "your connection request is accepted"
else "your contact's device is online"
chatModel.alertManager.showAlertMsg(
title = "Connection request sent!",
text = "You will be connected when $whenConnected, please wait or check later!"
)
}
}
@Composable
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloseSheetBar(close)
Text(
"Scan QR code",
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Your chat profile will be sent\nto your contact",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("If you cannot meet in person, you can ")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(", or you can create the invitation link.")
}
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp)
)
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewConnectContactLayout() {
SimpleXTheme {
ConnectContactLayout(
qrCodeScanner = { Surface {} },
close = {},
)
}
}
@@ -1,123 +0,0 @@
package chat.simplex.app.views.newchat
import android.Manifest
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ScaffoldController
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController, nav: NavController) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
NewChatSheetLayout(
addContact = {
withApi {
// show spinner
chatModel.connReqInvitation = chatModel.controller.apiAddContact()
// hide spinner
if (chatModel.connReqInvitation != null) {
newChatCtrl.collapse()
nav.navigate(Pages.AddContact.route)
}
}
},
scanCode = {
newChatCtrl.collapse()
nav.navigate(Pages.Connect.route)
cameraPermissionState.launchPermissionRequest()
},
close = {
newChatCtrl.collapse()
}
)
}
@Composable
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, close: () -> Unit) {
Row(Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 48.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Box(Modifier.weight(1F).fillMaxWidth()) {
ActionButton(
"Add contact", "(create QR code\nor link)",
Icons.Outlined.PersonAdd, click = addContact
)
}
Box(Modifier.weight(1F).fillMaxWidth()) {
ActionButton(
"Scan QR code", "(in person or in video call)",
Icons.Outlined.QrCode, click = scanCode
)
}
Box(Modifier.weight(1F).fillMaxWidth()) {
ActionButton(
"Create Group", "(coming soon!)",
Icons.Outlined.GroupAdd, disabled = true
)
}
}
}
@Composable
fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boolean = false,
click: () -> Unit = {}) {
Column(
Modifier
.clickable(onClick = click)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text,
tint = tint,
modifier = Modifier
.size(40.dp)
.padding(bottom = 8.dp))
Text(text,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold,
color = tint,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(comment,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body2
)
}
}
@Preview
@Composable
fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
addContact = {},
scanCode = {},
close = {},
)
}
}
@@ -1,40 +0,0 @@
package chat.simplex.app.views.newchat
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.ui.theme.SimpleXTheme
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
@Composable
fun QRCode(connReq: String) {
Image(
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
contentDescription = "QR Code"
)
}
fun qrCodeBitmap(content: String, size: Int): Bitmap {
val hints = hashMapOf<EncodeHintType, Int>().also { it[EncodeHintType.MARGIN] = 1 }
val bits = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
return Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565).also {
for (x in 0 until size) {
for (y in 0 until size) {
it.setPixel(x, y, if (bits[x, y]) Color.BLACK else Color.WHITE)
}
}
}
}
@Preview
@Composable
fun PreviewQRCode() {
SimpleXTheme {
QRCode(connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
}
}
@@ -1,108 +0,0 @@
package chat.simplex.app.views.newchat
import android.util.Log
import android.view.ViewGroup
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.*
// Bar code scanner adapted from https://github.com/MakeItEasyDev/Jetpack-Compose-BarCode-Scanner
@Composable
fun QRCodeScanner(onBarcode: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var preview by remember { mutableStateOf<Preview?>(null) }
AndroidView(
factory = { AndroidViewContext ->
PreviewView(AndroidViewContext).apply {
this.scaleType = PreviewView.ScaleType.FILL_CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
}
},
// modifier = Modifier.fillMaxSize(),
update = { previewView ->
val cameraSelector: CameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> =
ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
val barcodeAnalyser = BarCodeAnalyser { barcodes ->
barcodes.firstOrNull()?.rawValue?.let(onBarcode)
}
val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also { it.setAnalyzer(cameraExecutor, barcodeAnalyser) }
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
Log.d("SIMPLEX", "CameraPreview: ${e.localizedMessage}")
}
}, ContextCompat.getMainExecutor(context))
}
)
}
class BarCodeAnalyser(
private val onBarcodeDetected: (barcodes: List<Barcode>) -> Unit,
): ImageAnalysis.Analyzer {
private var lastAnalyzedTimeStamp = 0L
@ExperimentalGetImage
override fun analyze(image: ImageProxy) {
val currentTimestamp = System.currentTimeMillis()
if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) {
image.image?.let { imageToAnalyze ->
val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS)
.build()
val barcodeScanner = BarcodeScanning.getClient(options)
val imageToProcess = InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees)
barcodeScanner.process(imageToProcess)
.addOnSuccessListener { barcodes ->
if (barcodes.isNotEmpty()) {
onBarcodeDetected(barcodes)
} else {
Log.d("SIMPLEX", "BarcodeAnalyser: No barcode Scanned")
}
}
.addOnFailureListener { exception ->
Log.d("SIMPLEX", "BarcodeAnalyser: Something went wrong $exception")
}
.addOnCompleteListener {
image.close()
}
}
lastAnalyzedTimeStamp = currentTimestamp
} else {
image.close()
}
}
}
@@ -1,64 +0,0 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.views.helpers.CloseSheetBar
@Composable
fun HelpView(chatModel: ChatModel, nav: NavController) {
val user = chatModel.currentUser.value
if (user != null) {
HelpLayout(
displayName = user.profile.displayName,
back = nav::popBackStack
)
}
}
@Composable
fun HelpLayout(displayName: String, back: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.Start
) {
CloseSheetBar(back)
Text(
"Welcome $displayName!",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView({}, false)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewHelpView() {
SimpleXTheme {
HelpLayout(
displayName = "Alice",
back = {}
)
}
}
@@ -1,110 +0,0 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
@Composable
fun MarkdownHelpView(nav: NavController) {
MarkdownHelpLayout(nav::popBackStack)
}
@Composable
fun MarkdownHelpLayout(back: () -> Unit) {
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Column {
CloseSheetBar(back)
Column(Modifier.padding(horizontal = 16.dp)) {
Text(
"How to use markdown",
style = MaterialTheme.typography.h1,
)
Text(
"You can use markdown to format messages:",
Modifier.padding(vertical = 16.dp)
)
MdFormat("*bold*", "bold text", Format.Bold())
MdFormat("_italic_", "italic text", Format.Italic())
MdFormat("~strike~", "strikethrough text", Format.StrikeThrough())
MdFormat("`code`", "a = b + c", Format.Snippet())
Row {
MdSyntax("!1 colored!")
Text(buildAnnotatedString {
withStyle(Format.Colored(FormatColor.red).style) { append("red text") }
append(" (")
appendColor(this, "1", FormatColor.red, ", ")
appendColor(this, "2", FormatColor.green, ", ")
appendColor(this, "3", FormatColor.blue, ", ")
appendColor(this, "4", FormatColor.yellow, ", ")
appendColor(this, "5", FormatColor.cyan, ", ")
appendColor(this, "6", FormatColor.magenta, ")")
})
}
Row {
MdSyntax("#secret")
SelectionContainer {
Text(buildAnnotatedString {
withStyle(Format.Secret().style) { append("secret text") }
})
}
}
}
}
}
}
@Composable
fun MdSyntax(markdown: String) {
Text(markdown, Modifier
.width(100.dp)
.padding(bottom = 4.dp))
}
@Composable
fun MdFormat(markdown: String, example: String, format: Format) {
Row {
MdSyntax(markdown)
Text(buildAnnotatedString {
withStyle(format.style) { append(example) }
})
}
}
@Composable
fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) {
b.withStyle(Format.Colored(c).style) { append(s)}
b.append(after)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewMarkdownHelpView() {
SimpleXTheme {
MarkdownHelpLayout(back = {})
}
}
@@ -1,189 +0,0 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
fun SettingsView(chatModel: ChatModel, nav: NavController) {
val user = chatModel.currentUser.value
if (user != null) {
SettingsLayout(
profile = user.profile,
navigate = nav::navigate
)
}
}
val simplexTeamUri =
"simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
@Composable
fun SettingsLayout(
profile: Profile,
navigate: (String) -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(8.dp)
.padding(top = 16.dp)
) {
Text(
"Your Settings",
style = MaterialTheme.typography.h1,
)
Spacer(Modifier.height(30.dp))
SettingsSectionView({ navigate(Pages.UserProfile.route) }, 60.dp) {
Icon(
Icons.Outlined.AccountCircle,
contentDescription = "Avatar Placeholder",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
Text(
profile.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
)
Text(profile.fullName)
}
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ navigate(Pages.UserAddress.route) }) {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Address",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Your SimpleX contact address")
}
Spacer(Modifier.height(24.dp))
SettingsSectionView({ navigate(Pages.Help.route) }) {
Icon(
Icons.Outlined.HelpOutline,
contentDescription = "Chat help",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("How to use SimpleX Chat")
}
SettingsSectionView({ navigate(Pages.Markdown.route) }) {
Icon(
Icons.Outlined.TextFormat,
contentDescription = "Markdown help",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Markdown in messages")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
Icon(
Icons.Outlined.Tag,
contentDescription = "SimpleX Team",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Get help & advice via chat",
color = MaterialTheme.colors.primary
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
Icon(
Icons.Outlined.Email,
contentDescription = "Email",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Ask questions via email",
color = MaterialTheme.colors.primary
)
}
Spacer(Modifier.height(24.dp))
SettingsSectionView({ navigate(Pages.Terminal.route) }) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = "Chat console",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Chat console")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
buildAnnotatedString {
append("Install ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("SimpleX Chat for terminal")
}
}
)
}
}
}
}
@Composable
fun SettingsSectionView(func: () -> Unit, height: Dp = 48.dp, content: (@Composable () -> Unit)) {
Row(
Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.clickable(onClick = func)
.height(height),
verticalAlignment = Alignment.CenterVertically
) {
content.invoke()
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
navigate = {}
)
}
}
@@ -1,147 +0,0 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
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.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun UserAddressView(chatModel: ChatModel, nav: NavController) {
val cxt = LocalContext.current
UserAddressLayout(
userAddress = chatModel.userAddress.value,
back = { nav.popBackStack() },
createAddress = {
withApi {
chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
}
},
share = { userAddress: String -> shareText(cxt, userAddress) },
deleteAddress = {
chatModel.alertManager.showAlertMsg(
title = "Delete address?",
text = "All your contacts will remain connected",
confirmText = "Delete",
onConfirm = {
withApi {
chatModel.controller.apiDeleteUserAddress()
chatModel.userAddress.value = null
}
}
)
}
)
}
@Composable
fun UserAddressLayout(
userAddress: String?,
back: () -> Unit,
createAddress: () -> Unit,
share: (String) -> Unit,
deleteAddress: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top
) {
CloseSheetBar(back)
Text(
"Your chat address",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
"and if you later delete it - you won't lose your contacts.",
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
Column(
Modifier
.fillMaxWidth()
.padding(top = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
if (userAddress == null) {
SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
} else {
QRCode(userAddress)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SimpleButton(
"Share link",
icon = Icons.Outlined.Share,
click = { share(userAddress) })
SimpleButton(
"Delete address",
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteAddress
)
}
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserAddressLayoutNoAddress() {
SimpleXTheme {
UserAddressLayout(
userAddress = null,
back = {},
createAddress = {},
share = { _ -> },
deleteAddress = {},
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserAddressLayoutAddressCreated() {
SimpleXTheme {
UserAddressLayout(
userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
back = {},
createAddress = {},
share = { _ -> },
deleteAddress = {},
)
}
}
@@ -1,214 +0,0 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
@Composable
fun UserProfileView(chatModel: ChatModel, nav: NavController) {
val user = chatModel.currentUser.value
if (user != null) {
var editProfile by remember { mutableStateOf(false) }
var profile by remember { mutableStateOf(user.profile) }
UserProfileLayout(
editProfile = editProfile,
profile = profile,
back = { nav.popBackStack() },
editProfileOff = { editProfile = false },
editProfileOn = { editProfile = true },
saveProfile = { displayName: String, fullName: String ->
withApi {
val newProfile = chatModel.controller.apiUpdateProfile(
profile = Profile(displayName, fullName)
)
if (newProfile != null) {
chatModel.updateUserProfile(newProfile)
profile = newProfile
}
editProfile = false
}
}
)
}
}
@Composable
fun UserProfileLayout(
editProfile: Boolean,
profile: Profile,
back: () -> Unit,
editProfileOff: () -> Unit,
editProfileOn: () -> Unit,
saveProfile: (String, String) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.Start
) {
CloseSheetBar(back)
Text(
"Your chat profile",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Your profile is stored on your device and shared only with your contacts.\n" +
"SimpleX servers cannot see your profile.",
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
)
if (editProfile) {
var displayName by remember { mutableStateOf(profile.displayName) }
var fullName by remember { mutableStateOf(profile.fullName) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
// TODO hints
BasicTextField(
value = displayName,
onValueChange = { displayName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
BasicTextField(
value = fullName,
onValueChange = { fullName = it },
modifier = Modifier
.padding(bottom = 24.dp)
.fillMaxWidth(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
Row {
Text(
"Cancel",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOff),
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
"Save (and notify contacts)",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = { saveProfile(displayName, fullName) })
)
}
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Row(
Modifier.padding(bottom = 24.dp)
) {
Text(
"Display name:",
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.displayName,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
Row(
Modifier.padding(bottom = 24.dp)
) {
Text(
"Full name:",
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
profile.fullName,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
}
Text(
"Edit",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editProfileOn)
)
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
profile = Profile.sampleData,
editProfile = false,
back = {},
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserProfileLayoutEditOn() {
SimpleXTheme {
UserProfileLayout(
profile = Profile.sampleData,
editProfile = true,
back = {},
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
)
}
}
@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Android drawable generated by fa5ad-free project:
https://github.com/diwanoczko/fa5ad-free
Resource generated base on Font Awesome 5 Free icons set:
https://fontawesome.com/
All brand icons are trademarks of their respective owners.
Please do not use brand logos for any purpose except to represent the
company, product, or service to which they refer.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="23.25dp"
android:height="24dp"
android:viewportWidth="496"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
/>
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M20,4H4C2.89,4 2,4.9 2,6v12c0,1.1 0.89,2 2,2h16c1.1,0 2,-0.9 2,-2V6C22,4.9 21.11,4 20,4zM20,18H4V8h16V18zM18,17h-6v-2h6V17zM7.5,17l-1.41,-1.41L8.67,13l-2.59,-2.59L7.5,9l4,4L7.5,17z"/>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@mipmap/icon_foreground"/>
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@color/white"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_background">#FFFFFF</color>
</resources>
@@ -1,3 +0,0 @@
<resources>
<string name="app_name">SimpleX</string>
</resources>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.SimpleX" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/black</item>
</style>
</resources>
-27
View File
@@ -1,27 +0,0 @@
buildscript {
ext {
compose_version = '1.1.0'
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.1.1' apply false
id 'com.android.library' version '7.1.1' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
}
task clean(type: Delete) {
delete rootProject.buildDir
}
-69
View File
@@ -1,69 +0,0 @@
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
Libraries/
Shared/MyPlayground.playground/*
@@ -1,23 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.533",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"localizable" : true
}
}
@@ -1,172 +0,0 @@
{
"images" : [
{
"filename" : "Icon-App-20x20@2x-1.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "Icon-App-20x20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "Icon-App-29x29@1x.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "Icon-App-29x29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "Icon-App-29x29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "Icon-App-40x40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "Icon-App-40x40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "Icon-App-60x60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "Icon-App-60x60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "Icon-App-20x20@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
},
{
"filename" : "Icon-App-20x20@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "Icon-App-29x29@1x-1.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
},
{
"filename" : "Icon-App-29x29@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "Icon-App-40x40@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
},
{
"filename" : "Icon-App-40x40@2x-1.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "Icon-App-76x76@1x.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
},
{
"filename" : "Icon-App-76x76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "Icon-App-83.5x83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "Icon-App-1024x1024@1x.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

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