Compare commits

..

140 Commits

Author SHA1 Message Date
Efim Poberezkin
c580c34a35 1.2.0 2022-02-14 21:55:39 +04:00
Efim Poberezkin
fdf312d9e1 ios: add contactNotReady error type (#304) 2022-02-14 21:52:01 +04:00
Evgeny Poberezkin
44d8b549c4 return version number to mobile (#303) 2022-02-14 21:51:50 +04:00
Efim Poberezkin
928dd27043 prepare v1.2.0 (#302) 2022-02-14 21:21:16 +04:00
Efim Poberezkin
4419051347 connection precedence logic in getContact_ (fixes asynchronous establishment of connection) (#300) 2022-02-14 18:49:42 +04:00
Evgeny Poberezkin
8cf88019e5 ios public beta announcement (#298)
* ios public beta announcement

* update post

* corrections

* corrections

* update blog links

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-02-14 13:48:21 +00:00
Evgeny Poberezkin
710971a0cd show confirmation alert after the connection (#299)
* show confirmation alert after the connection

* update build number
2022-02-14 11:53:44 +00:00
Efim Poberezkin
dc306dfcd0 option to auto-accept contact requests (#296) 2022-02-14 14:59:11 +04:00
Mark Aleksander Hil
e90520a5ec update banner (#297) 2022-02-14 10:29:16 +00:00
Evgeny Poberezkin
7805bd1e45 show large unread numbers 2022-02-13 10:09:09 +00:00
Efim Poberezkin
c1c55ca700 deduplicate contact requests (#287)
* deprecate XContact

* XInfoId

* xInfoId tests

* merging

* saving on connection

* connectByAddress

* remove old connect

* deduplicate contact requests

* check on contact acceptance

* test

* rename response

* reuse CRContactRequestAlreadyAccepted

* Update src/Simplex/Chat.hs

* createConnReqConnection

* simplify controller logic

* store methods + profile change

* index

* more indices

* unXInfoId

* simplify

* XInfo with ID -> XContact

* sync reply to Connect when contact already exists

* update view for sync CRContactAlreadyExists command response

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-13 09:19:24 +00:00
Evgeny Poberezkin
8e34d2fbbc fix swift 2022-02-13 09:13:06 +00:00
Evgeny Poberezkin
61afb64dd7 search chats, longer emojis (#295)
* search chats, longer emojis

* simplify
2022-02-13 08:45:08 +00:00
Evgeny Poberezkin
aa2bc545db update build number (8) 2022-02-12 18:02:52 +00:00
Evgeny Poberezkin
067f122b05 iOS app version 0.3.1 2022-02-12 17:28:37 +00:00
Evgeny Poberezkin
9d9bb68d50 iOS: show message sent/unread status (#293)
* light github image for dark mode

* show message received status, remove chevrons in chat list

* show unread message status

* add message send error mark

* refactor alerts to use AlertManager

* show alert message on tapping undelivered message, simplify text-only alerts
2022-02-12 15:59:43 +00:00
Efim Poberezkin
af5abae558 fix group leave (#294)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-12 13:17:11 +04:00
Efim Poberezkin
0ea8705014 1.1.1 2022-02-11 12:05:22 +04:00
Efim Poberezkin
92409820fb enable async commands (#290)
* enable async

* fix async command error response

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-11 12:03:34 +04:00
Evgeny Poberezkin
98fc6c6adf chat usage help and minor UI fixes (#291)
* chat usage help and minor UI fixes

* update version, build and binary
2022-02-11 07:42:00 +00:00
Efim Poberezkin
771bc6a14d prepare v1.1.1 (#289) 2022-02-10 20:08:29 +04:00
Evgeny Poberezkin
86c36f53e4 simplify and fix background loading (#288)
* simplify and fix background loading

* start receive loop in the main chat
2022-02-10 15:52:11 +00:00
Efim Poberezkin
5c24089f9f check group member connection status before delivery; best effort delivery per group member (#286) 2022-02-10 17:03:36 +04:00
Evgeny Poberezkin
516c8d79ad receiving messages in the background and sending local notifications (#284)
* receiving messages in the background and sending local notifications

* show notifications in foreground and background

* presentation logic for notification options when app is in the foreground

* background refresh works

* remove async dispatch
2022-02-09 22:53:06 +00:00
Efim Poberezkin
ff7a8cade1 test chat items (#285) 2022-02-09 20:58:02 +04:00
Efim Poberezkin
7af4cdffee add unreadCount and minUnreadItemId stats to Chat type (#283) 2022-02-08 20:38:57 +04:00
Efim Poberezkin
b06838b651 add APIChatRead chat command (#282) 2022-02-08 17:27:43 +04:00
Evgeny Poberezkin
b3a4c21c4b updated text items (#278)
* updated text items

* update version

* fix JSON parsing in CIDirection, refactor data samples

* show group member in received messages and chat preview

* use profile displayName instead of localDisplayName, do not show fullName if it is the same as displayName
2022-02-08 09:19:25 +00:00
Efim Poberezkin
855881094b add CRContactConnecting api response (#281) 2022-02-08 13:04:17 +04:00
Efim Poberezkin
82d02e923a ios: add CIStatus type (#280) 2022-02-08 11:20:41 +04:00
Efim Poberezkin
d11d66fa90 connection precedence logic in getDirectChatPreviews_; update item status in object (#279) 2022-02-07 18:34:54 +04:00
Efim Poberezkin
f5507436f3 chat item status, CRChatItemUpdated api response (#269) 2022-02-07 15:19:34 +04:00
Evgeny Poberezkin
eeea33c7cb fix loading chat, contact connection status info (#277) 2022-02-07 10:36:11 +00:00
Evgeny Poberezkin
7883ca7657 improve text message view (#276)
* show text and time on the same line

* convert emails and phones to links
2022-02-06 21:06:02 +00:00
Evgeny Poberezkin
8efb8b2f86 use simplified chat controller, fix keyboard removing on tap (#275) 2022-02-06 18:26:22 +00:00
Evgeny Poberezkin
408a30c25b simplify mobile API to have single controller (#274)
* simplify mobile API to have single controller

* update chat response in swift

* add async to stack
2022-02-06 16:18:01 +00:00
Evgeny Poberezkin
9b67aa537a each command takes lock if it needs it (#273) 2022-02-06 08:21:40 +00:00
Evgeny Poberezkin
5aabf87898 ios: highlight URLs in texts (#272)
* ios: highlight URLs in texts

* Apply suggestions from code review
2022-02-06 07:44:41 +00:00
Evgeny Poberezkin
67dbdcd257 contact and server connection info (#271) 2022-02-05 20:10:47 +00:00
Evgeny Poberezkin
3d137995d8 multiline message entry field (#270) 2022-02-05 14:24:23 +00:00
Evgeny Poberezkin
e424e9328b large emojis, full contact names, contact createdAt, process profile updates, etc. (#268) 2022-02-04 22:13:52 +00:00
Evgeny Poberezkin
214ecf605b minor UI improvements (#267) 2022-02-04 16:31:08 +00:00
Evgeny Poberezkin
7d06d0660d Merge pull request #266 from simplex-chat/ep/fix-utf8-api
fix utf8 encoding for C API requests
2022-02-04 12:46:45 +00:00
Evgeny Poberezkin
c34eddb82a fix utf8 encoding for C API requests 2022-02-04 12:41:43 +00:00
Efim Poberezkin
9969606432 fix utf8 encoding when writing to database 2022-02-04 14:30:00 +04:00
Evgeny Poberezkin
d8abdb7927 Merge pull request #265 from simplex-chat/ep/sync-cmd
fix C string UTF8 encoding, revert to sync commands
2022-02-04 08:50:52 +00:00
Evgeny Poberezkin
71a60795cf Merge pull request #263 from simplex-chat/ep/ios-fixes
configure build for device/simulator
2022-02-04 08:17:18 +00:00
Evgeny Poberezkin
d07ce0b8f4 use 8 byte characters, as encoding is handled elsewhere 2022-02-04 08:15:25 +00:00
Evgeny Poberezkin
565bc70843 sync commands 2022-02-04 08:02:48 +00:00
Efim Poberezkin
7924861810 sort chat items by id (#264) 2022-02-04 11:12:12 +04:00
Evgeny Poberezkin
08dd92b726 configure build for device/simulator 2022-02-03 18:22:05 +00:00
Evgeny Poberezkin
dca5dc4fce iOS version 1.0.1 2022-02-03 07:18:17 +00:00
Evgeny Poberezkin
24f3637199 add animations (#260)
* add animations

* improve settings screen

* app icons
2022-02-03 07:16:29 +00:00
Efim Poberezkin
4dd95c1639 create release as prerelease; fix windows build (#261) 2022-02-03 10:15:38 +04:00
Efim Poberezkin
4724669bce prepare v1.1.0 (#259) 2022-02-02 23:50:43 +04:00
Evgeny Poberezkin
292c334460 make slow commands asynchronous (#258) 2022-02-02 21:47:27 +04:00
Evgeny Poberezkin
dafdf66ada update entity connection status to report it correctly (#257) 2022-02-02 17:01:12 +00:00
Evgeny Poberezkin
38424af48e refactor files, auto-scrollback for messages (#256) 2022-02-02 16:46:05 +00:00
Efim Poberezkin
88a33990b7 sort chats w/t items by time of creation; created_at & updated_at in all tables; merge v1.1 migrations (#255)
* merge migrations; timestamps

* contact created_at

* group, contact request created_at

* sort

* redundant imports
2022-02-02 16:25:36 +00:00
Evgeny Poberezkin
7ce305e16f ios: fix message view updates (refactor model to make it shallow) (#254) 2022-02-02 12:51:39 +00:00
Evgeny Poberezkin
1d1ba8607e send message integrity errors to view as a separate notification (#253) 2022-02-02 11:43:52 +00:00
Evgeny Poberezkin
9f6385f763 update connection status in entity used in controller notifications (#252)
* update connection status in entity used in controller notifications

* remove unused code
2022-02-02 11:31:01 +00:00
Evgeny Poberezkin
a68b591029 connect via link with simplex: protocol (#251) 2022-02-01 20:30:33 +00:00
Evgeny Poberezkin
711207743b add support for user addresses (#246)
* add support for user addresses

* started processing contact requests

* update command syntax

* fix: make Profile Codable

* accept/reject contact requests

* update API, accept/reject contact requests
2022-02-01 17:34:06 +00:00
Efim Poberezkin
a8a7bb3c99 return accepted contact from APIAcceptContact (#250) 2022-02-01 17:04:44 +04:00
Efim Poberezkin
228c118714 api for chat pagination (#249) 2022-02-01 15:05:27 +04:00
Evgeny Poberezkin
0b86402ce3 fix constructor name for JSON encoding (#248) 2022-02-01 07:16:02 +00:00
Evgeny Poberezkin
2295f7a92b update commands (#247) 2022-02-01 09:31:34 +04:00
Evgeny Poberezkin
8e03eefa9b update API commands syntax 2022-01-31 23:20:52 +00:00
Evgeny Poberezkin
53040dbe1d iOS: chats and messages layout (#241)
* iOS: chats and messages layout

* model update for updated API

* improve chat list view

* chat view layouts

* delete contacts

* larger headers, clean up, move message reception loop to ContentView

* settings: user profile
2022-01-31 21:28:07 +00:00
Efim Poberezkin
6d5b5ab44f getContactRequestChatPreviews_ (#245) 2022-01-31 22:43:39 +04:00
Efim Poberezkin
0a18985e68 contact requests api (#244)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-31 21:53:53 +04:00
Efim Poberezkin
047aa7deef delete contact api (#243)
* delete contact api

* chat command
2022-01-31 11:14:56 +00:00
Efim Poberezkin
945ed3f7cb fix queries returning duplicate contacts (#242) 2022-01-31 13:20:26 +04:00
Evgeny Poberezkin
e29ea99d2c getChats returns [Chat] with 0-1 item instead of [ChatPreview] (#240) 2022-01-30 21:51:23 +00:00
Evgeny Poberezkin
3b19aaf1d1 iOS: send/receive messages in chats, connect via QR code (#238)
* send messages from chats

* update API to use chat IDs

* send messages to groups

* generate invitation QR code

* connect via QR code
2022-01-30 18:27:20 +00:00
Evgeny Poberezkin
15a91278d6 API to send direct and group messages (#239)
* API to send direct and group messages

* update API parsing
2022-01-30 10:49:13 +00:00
Evgeny Poberezkin
cb602dd377 show received messages in chat, send command on Enter, fix Date parsing (#237)
* refactor UI and API, send command on Enter, fix Date parsing

* UI sheets to create connection and groups

* show received messages

* readme
2022-01-29 23:37:02 +00:00
Efim Poberezkin
7e2f365c1c ios: group chat preview (#235) 2022-01-29 20:35:20 +00:00
Evgeny Poberezkin
8425be0612 use aeson fork with nullableToObject option to make JSON compatible with Swift (#236) 2022-01-29 20:21:37 +00:00
Efim Poberezkin
c0199a38fd add readme for ios setup (#234) 2022-01-29 16:53:24 +04:00
Efim Poberezkin
d97a8c1934 getGroupChat, getGroupChatPreviews_ (#233) 2022-01-29 16:06:08 +04:00
Evgeny Poberezkin
7c36ee7955 swift API for chat, started chat UI (#228)
* started swift API for chat

* skeleton UI

* show all chat responses in Terminal view

* show chat list in UI

* refactor swift API
2022-01-29 11:10:04 +00:00
Efim Poberezkin
55dde3531e most recent chat items in getDirectChatPreviews_ (#232) 2022-01-28 19:24:31 +04:00
Evgeny Poberezkin
c3a8ae1eb5 chats API for mobile (#230)
Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-28 14:41:09 +04:00
Efim Poberezkin
edc9560d36 getDirectChat (#227)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-28 11:52:10 +04:00
Evgeny Poberezkin
37cfb93217 switch to JSON single field encodings for sum types to align with Swift enums (#229) 2022-01-27 22:01:15 +00:00
Evgeny Poberezkin
28ee40074a update sha256map.nix 2022-01-26 22:49:44 +00:00
Evgeny Poberezkin
0ba4598ca2 JSON encoding for ChatResponse and all other types used in mobile API (#226)
* JSON encoding for ChatResponse and all other types used in mobile API

* omit null corrId in response, refactor

* more JSON field names
2022-01-26 21:20:08 +00:00
Efim Poberezkin
ecb5b0fdeb add getChatPreviews to Store (#225) 2022-01-26 21:19:46 +04:00
Efim Poberezkin
6cf23f1fd1 chat items (#223)
* add chat items migration

* chat and chat items types

* queries draft

* ChatInfo with optional ChatItem

* schema adjustments

* flat schema and queries

* refactor ChatResponse using ChatItem types

* schema adjustments

* refactor GroupInfo to include GroupMember of the user

* remove Message

* createNewChatItem, sendDirectChatItem

* refactor to use GroupInfo in Chat type and all ChatResponses

* replace ContactName with Contact in some ChatResponse constructors

* remove Group selectors

* minor correction

* refactor

* refactor 2

* nullable created_by_msg_id

* remove normalized schema and queries

* ON DELETE CASCADE / SET NULL

* CIContent to Text

* files chat_item_id

* fix

* apply ciContentToText

* queries folder

* refactor

* moar refactor

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-26 16:18:27 +04:00
Evgeny Poberezkin
b86f034c0b update C api to return JSON messages via chat_recv_msg (#224) 2022-01-24 22:52:55 +00:00
Evgeny Poberezkin
ce3d7f21b0 haskell nix flake CI (#222)
* Adds preliminary flake

This nix flake should be enough to try and build an android library.

* add sha256map

* bump

* bump index-state

Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2022-01-24 19:42:41 +00:00
Evgeny Poberezkin
b38d5f3465 started chat model (#221)
* started chat model

* refactor processing commands and UI events

* message chat event processing

* groups: delayed delivery of messages and introductions to announced members (#217)

* combine migrations, rename fields

* show all view messages vis ChatResponse type

* serialize chat response

* update C api

* remove unused extensions, fix typos

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-24 16:07:17 +00:00
Evgeny Poberezkin
a5ad0b185c use Haskell library (#220) 2022-01-22 17:54:22 +00:00
Evgeny Poberezkin
4f5e135992 test android app (#219) 2022-01-22 17:54:06 +00:00
Evgeny Poberezkin
50d83d2374 prepare v1.0.2 (#218)
* update dependencies

* update version and dependencies

* add tls@1.5.7 to stack.yaml

* update readme

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-21 18:58:43 +00:00
Evgeny Poberezkin
64381be91d export C interface, started mobile app (#210)
* initial mobile app design draft

* add proposals

* xcode project

* refactor function to send to view as parameter

* export C interface

* remove unused files

* run chat from chatInit

* split chatStart to a separate function

* replace file-embed with QQ

* add mobile views

* server using IP address

* pass dbFilePrefix as parameter to chatInit

* comment on enabling logging

* fix mobile db config

* update C API, make user non-optional in ChatController

* restore SMP server addresses

* revert the change in the tests

* flip dependency - now Controller depends on Terminal

* make ChatController independent of terminal package

* fix Main.hs

* add iOS .gitignore

* refactor Simplex.Chat.Terminal

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-21 11:09:33 +00:00
Evgeny Poberezkin
f47494e5c8 add loggin option to test 2022-01-20 20:23:21 +00:00
Efim Poberezkin
32a105bac8 fix Windows CI to fail when commands fail, use fixed terminal package (#214)
* fix windows CI

* use fixed terminal package

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-20 20:19:39 +00:00
Efim Poberezkin
65b17c9d18 add option to enable logging (#216)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-20 12:18:00 +04:00
Evgeny Poberezkin
aef159b097 readme: building from stable branch 2022-01-18 20:39:16 +00:00
Evgeny Poberezkin
d29a6542de 1.0.1 2022-01-18 20:19:05 +00:00
Evgeny Poberezkin
aef697e30a make tests independent of JSON fields order (#212) 2022-01-17 12:24:55 +00:00
Evgeny Poberezkin
fca063e131 Fork/fix terminal libary to work on Apple M1 (#211)
* use forked terminal with CapiFFI (fails to compile)

* update terminal package git tag

* add terminal fork to stack.yaml
2022-01-16 15:22:58 +00:00
Efim Poberezkin
8a859044cb fix explanation of server fingerprint (#207) 2022-01-13 10:23:34 +04:00
Evgeny Poberezkin
895e3878f9 add cabal.project (#205)
* add cabal.project

* update meta-data in package.yaml
2022-01-12 21:18:54 +00:00
Evgeny Poberezkin
b2556e3306 add blog (#187)
* blog: v1 release notes

* Update 20220112-simplex-chat-v1-released.md (#181)

* Update 20220112-simplex-chat-v1-released.md (#183)

updated intro and "journalist" description.

* add blog posts

* make corrections to v1 blog (#188)

* update 20210512-simplex-chat-terminal-ui.md (#192)

* Update 20210512-simplex-chat-terminal-ui.md

* Update 20210914-simplex-chat-v0.4-released.md

* Update 20210914-simplex-chat-v0.4-released.md

* update blog post

* add blog toc and old post

Co-authored-by: Mark Aleksander Hil <32651095+markaleksanderh@users.noreply.github.com>
Co-authored-by: Rob Chandhok <rob@chandhok.net>
Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-12 19:52:40 +00:00
Efim Poberezkin
eebc24086b fix installation script (#204) 2022-01-12 23:16:05 +04:00
Evgeny Poberezkin
94bbc44960 1.0.0 2022-01-12 18:06:30 +00:00
Evgeny Poberezkin
78712541f0 update "incompatible link" message 2022-01-12 18:01:57 +00:00
Evgeny Poberezkin
2b4bdf39fb Merge pull request #196 from simplex-chat/v1
v1
2022-01-12 17:44:05 +00:00
Evgeny Poberezkin
a8faaef54e team user address, remove onboarding 2022-01-12 17:37:46 +00:00
Evgeny Poberezkin
44bad8e093 rename migration file 2022-01-12 16:42:08 +00:00
Efim Poberezkin
a988ab84f9 restore bracket in readme 2022-01-12 20:40:29 +04:00
Evgeny Poberezkin
85e2013639 update simplexmq version 2022-01-12 16:39:08 +00:00
Evgeny Poberezkin
1978801561 add tests for group deletion; remove restrict constraints (#203)
* add tests for group deletion

* update constraints

* move index

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-12 16:32:22 +00:00
Mark Aleksander Hil
95a4da71cb update banner image (#202)
* Add files via upload

* Updated logo

Added line back in
2022-01-12 16:31:05 +00:00
Efim Poberezkin
f13a65ca85 update chat README for v1 (#201)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-12 16:24:41 +04:00
Evgeny Poberezkin
e87be44134 update server addresses 2022-01-12 11:54:40 +00:00
Evgeny Poberezkin
fb8dfa02f2 merge database migration, rename field in group_members: inv_queue_info to sent_inv_queue_info (#200) 2022-01-12 10:35:52 +00:00
Evgeny Poberezkin
67e0ca28a9 additional notifications (#199) 2022-01-12 06:55:04 +00:00
Evgeny Poberezkin
7438db0a7d update file chunk size (#198) 2022-01-12 06:07:49 +00:00
Evgeny Poberezkin
b47f064115 Simplex chat logo (#197)
* Updated SimpleX Chat logo

* Updated logo

Co-authored-by: Mark Aleksander Hil <32651095+markaleksanderh@users.noreply.github.com>
2022-01-11 21:28:15 +00:00
Efim Poberezkin
d9afc47993 update install.sh to check for v0 and ask to continue (#184)
* update install.sh to check for v0 and ask to continue

* pseudo code

* pseudo

* continue

* continue

* continue

* implement logic

* tab

* full path to agent db

* Update install.sh

* Update install.sh

* Update install.sh

* Update install.sh

* Update install.sh

* Update install.sh

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

* Update install.sh

* Update install.sh

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-11 21:26:51 +00:00
Evgeny Poberezkin
fcee108863 Merge branch 'master' into v1 2022-01-11 21:25:18 +00:00
Evgeny Poberezkin
5a74b8066f prepare v1 release (#189)
* update servers

* update version

* update simplexmq version

* update database file names

* update server fingerprints and simlexmq

* update simplexmq commit

* fix port in tests

* update tls fixtures (#193)

* add -v cli option; print update instructions on -v and /v (#194)

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-11 21:23:57 +00:00
Efim Poberezkin
809a87ce61 script to add message views to database (#195) 2022-01-11 23:22:59 +04:00
Evgeny Poberezkin
c2c05816f3 binary encoding for file chunks (#191) 2022-01-11 12:41:38 +00:00
Evgeny Poberezkin
cc4fff0ae5 tests for JSON message encoding/decoding (#190)
* tests for JSON message encoding/decoding

* fix XContact parsing to allow absent field "content"

* update XContact JSON encoding
2022-01-11 12:27:57 +00:00
Evgeny Poberezkin
be537f3a24 update chat protocol to use JSON encoding for chat messages (#182)
* started chat protocol

* text message example

* events json

* same style comments

* jsonc

* num for rendering

* try to fix comment rendering

* revert num

* chat protocol: make msg params closer to types

* AppMessage type

* combine new and old simplexmq dependencies

* json parsers

* version-compatible types for connection requests

* more parsers

* remove import

* decode/encode from/to AppMessage

* make group invitation a property in params

* switch chat to the new agent

* remove "compatibility" attempt

* new JSON encoding for chat messages

* simplexmq from github

* update MsgContent name

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-11 08:50:44 +00:00
Efim Poberezkin
7498cd4432 0.5.5 (#179) 2022-01-07 11:32:06 +04:00
Efim Poberezkin
5e545b639f update simplex-chat.cabal (#178) 2022-01-07 11:28:39 +04:00
Evgeny Poberezkin
1093b01e7e update simplex.md (#133)
* switch to ghc-8.10.7 (lts-18.17 resolver) (#125)

* update simplex.md

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-06 23:11:53 +00:00
Evgeny Poberezkin
44845ad563 refactor closure (#177) 2022-01-06 20:29:57 +00:00
Efim Poberezkin
1bfa7f1104 allow to repeat group invitation using saved queue info; recognize it's the same group at invitee (#176)
* naming; full names on start for groups

* allow to re-add member

* save and reuse connection request

* TODO

* wording

* index

* user id

* revert to listToMaybe . map fromOnly

* add to test

* fix null conversion

* Update src/Simplex/Chat.hs

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* Update src/Simplex/Chat.hs

* fix

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-01-06 23:39:58 +04:00
Evgeny Poberezkin
79658b3d8d update simplexmq to 0.5.2, update resolver (#175)
* groups when in status invited - list as invitations on /gs

* don't list on start

* test

* refactor

* getUserGroupDetails

* update simplexmq to 0.5.2, update resolver

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-01-06 16:03:45 +00:00
Efim Poberezkin
962287c439 unprocessed group invitations - highlight, print on start (#174) 2022-01-06 14:24:33 +04:00
Efim Poberezkin
ea89c9d8c8 groups when in status invited - list as invitations on /gs; do not list on start (#173) 2022-01-06 13:09:03 +04:00
Efim Poberezkin
7c723213c2 allow invitee to delete group when in status invited (#172) 2022-01-05 20:46:35 +04:00
190 changed files with 13843 additions and 3278 deletions

View File

@@ -1,4 +1,4 @@
{
"template": "${{UNCATEGORIZED}}",
"pr_template": "- ${{TITLE}}\n"
"template": "Commits:\n${{UNCATEGORIZED}}",
"pr_template": "- ${{TITLE}}"
}

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
- master
- v4
- stable
tags:
- "v*"
pull_request:
@@ -32,6 +32,7 @@ jobs:
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true
files: |
LICENSE
fail_on_unmatched_files: true
@@ -49,24 +50,15 @@ 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
@@ -75,7 +67,7 @@ jobs:
- name: Setup Stack
uses: haskell/actions/setup@v1
with:
ghc-version: '8.8.4'
ghc-version: '8.10.7'
enable-stack: true
stack-version: 'latest'
@@ -85,17 +77,51 @@ jobs:
path: ${{ matrix.cache_path }}
key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }}
- name: Build & test
id: build_test
run: |
stack build ${{ matrix.stack_args }}
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
# / Unix
- name: Upload binaries to release
if: startsWith(github.ref, 'refs/tags/v')
- name: Unix build
id: unix_build
if: matrix.os != 'windows-latest'
shell: bash
run: |
stack build --test
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'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.build_test.outputs.LOCAL_INSTALL_ROOT }}${{ matrix.artifact_rel_path }}
file: ${{ steps.unix_build.outputs.local_install_root }}/bin/simplex-chat
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 /

14
.gitignore vendored
View File

@@ -5,12 +5,6 @@
*.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/
@@ -42,9 +36,9 @@ cabal.project.local~
.ghc.environment.*
stack.yaml.lock
# Idris
*.ibc
# chat database
# Chat database
*.db
*.db.bak
# Temporary test files
tests/tmp

136
README.md
View File

@@ -2,7 +2,7 @@
# SimpleX Chat
**The world's most private and secure chat** - open-source, decentralized, and without global identities of any kind.
SimpleX - private and secure open-source chat and application platform - public beta for iOS now available!
[![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)
@@ -10,13 +10,11 @@
[![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 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). The motivation for SimpleX chat is [presented here](./simplex.md). See [simplex.chat](https://simplex.chat) website for chat demo and the explanations of the system and how SMP protocol works.
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.5.4: [messages persistence](#access-chat-history)**
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
**NEW in v0.5.0: [user contact addresses](#user-contact-addresses-alpha)**
**Please note**: v0.5.0 of SimpleX Chat works with the same database, but the connection links are not compatible with the prior versions - please ask all your contacts to upgrade!
***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).*
### :zap: Quick installation
@@ -26,22 +24,6 @@ curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/inst
Once the chat client is installed, simply run `simplex-chat` from your terminal.
### :wave: Welcome
**We are building the world's most private and secure chat**. If you would like to support it, you can do so in the following ways:
- 🌟 **Star it on GitHub** - it helps us raise the visibility of the project.
- **Install the chat and try it out** - if you spot a bug, please [raise an issue](https://github.com/simplex-chat/simplex-chat/issues).
- :speech_balloon: **Spread the word** - terminal chat is an [early-stage product](#disclaimer) while we stabilize the protocol - you can invite your friends for some fun chat inside your terminal. We're using it right inside our IDEs as we are coding it 👨‍💻
- **Make a donation** via [opencollective](https://opencollective.com/simplex-chat) - every donation helps, however large or small!
- **Make a contribution to the project** - we're constantly moving the project forward and there are always lots of things to do!
We appreciate all the help from our contributors, thank you!
![simplex-chat](./images/connection.gif)
## Table of contents
@@ -52,7 +34,6 @@ We appreciate all the help from our contributors, thank you!
- [Installation](#🚀-installation)
- [Download chat client](#download-chat-client)
- [Linux and MacOS](#linux-and-macos)
- [Troubleshooting on Unix](#troubleshooting-on-unix)
- [Windows](#windows)
- [Build from source](#build-from-source)
- [Using Docker](#using-docker)
@@ -62,26 +43,26 @@ We appreciate all the help from our contributors, thank you!
- [How to use SimpleX chat](#how-to-use-simplex-chat)
- [Groups](#groups)
- [Sending files](#sending-files)
- [User contact addresses](#user-contact-addresses-alpha)
- [User contact addresses](#user-contact-addresses)
- [Access chat history](#access-chat-history)
- [Roadmap](#Roadmap)
- [License](#license)
## Disclaimer
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.
SimpleX Chat implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
If you expect software to be reliable most of the time, then this is probably not ready for you yet. We use it ourselves for terminal chat and it seems to work most of the time - we would really appreciate if you try SimpleX Chat and give us your feedback!
[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.
> :warning: **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.
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.
## Network topology
SimpleX is a decentralized client-server network that uses redundant, disposable nodes to asynchronously pass messages via message queues, providing receiver and sender anonymity.
SimpleX is a client-server network that uses redundant, disposable nodes to asynchronously pass 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 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.
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.
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.
@@ -95,13 +76,15 @@ The routing of messages relies on the knowledge of client devices how user conta
- 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.
- 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.
- 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)).
- Message integrity validation (via including the digests of the previous messages).
- Authentication of each command/message by SMP servers with automatically generated RSA key pairs.
- TCP transport encryption using SMP transport protocol.
- 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.
RSA keys are not used as identity, they are randomly generated for each contact.
Public keys involved in key exchange 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>
@@ -134,30 +117,6 @@ mv <binary> ~/.local/bin/simplex-chat
On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491).
##### Troubleshooting on Unix
If you get `simplex-chat: command not found` when executing the downloaded binary, you need to add the directory containing it to the [`PATH` variable](https://man7.org/linux/man-pages/man7/environ.7.html) (find "PATH" in page). To modify `PATH` for future sessions, put `PATH="$PATH:/path/to/dir"` in `~/.profile`, or in `~/.bash_profile` if that's what you have. See [this answer](https://unix.stackexchange.com/a/26059) for the detailed explanation on the appropriate place to define environment variables for `bash` and other shells.
For example, if you followed the previous instructions, open `~/.profile` for editing:
```sh
vi ~/.profile
```
And add the following line to the end:
```sh
PATH="$PATH:$HOME/.local/bin"
```
Note that this will not automatically update your `PATH` for the remainder of the session. To do this, you should run:
```sh
source ~/.profile
```
Or restart your terminal to start a new session.
#### Windows
```sh
@@ -166,6 +125,8 @@ 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):
@@ -173,6 +134,7 @@ 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 .
```
@@ -191,6 +153,7 @@ and build the project:
```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ stack install
```
@@ -198,9 +161,9 @@ $ stack install
### Running the chat client
To start the chat client, run `simplex-chat` from the terminal. If you get `simplex-chat: command not found`, see [Troubleshooting on Unix](#troubleshooting-on-unix).
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.chat.db` and `simplex.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_v1_chat.db` and `simplex_v1_agent.db` are initialized in it.
To specify a different file path prefix for the database files use `-d` command line option:
@@ -208,17 +171,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.chat.db` and `alice.agent.db` database files in current directory.
Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory.
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.
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).
If you deployed your own SMP server(s) you can configure client via `-s` option:
```shell
$ simplex-chat -s smp.example.com:5223#KXNE1m2E1m0lm92WGKet9CL6+lO742Vy5G6nsrkvgs8=
$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com
```
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.
Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake.
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).
@@ -238,7 +201,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 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 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 contact who received the invitation should enter `/c <invitation>` to accept the connection. This establishes the connection, and both parties are notified.
@@ -262,7 +225,7 @@ 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 (alpha)
### 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>`.
@@ -272,47 +235,28 @@ User address is "long-term" in a sense that it is a multiple-use connection link
Use `/help address` for other commands.
> :warning: **Please note:** This is an "alpha" feature - at the moment there is nothing to prevent someone who has obtained this address from spamming you with connection requests; countermeasures will be added soon! (In the short term, you can simply delete the long-term address you created if it starts getting abused.)
![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.
You can view and search your chat history by querying your database:
You can view and search your chat history by querying your database. Run the below script to create message views in your database.
```
sqlite3 ~/.simplex/simplex.chat.db
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db
```
Now you can run queries against `direct_messages`, `group_messages` and `all_messages` (or their simpler alternatives `direct_messages_plain`, `group_messages_plain` and `all_messages_plain`), for example:
Open SQLite Command Line Shell:
```sql
-- you can put these or your preferred settings into ~/.sqliterc to persist across sqlite3 client sessions
.mode column
.headers on
-- simple views into direct, group and all_messages with user's messages deduplicated for group and all_messages
-- only 'x.msg.new' ("new message") chat events - filters out service events
-- msg_sent is 0 for received, 1 for sent
select * from direct_messages_plain;
select * from group_messages_plain;
select * from all_messages_plain;
-- query other details of your chat history with regular SQL
select * from direct_messages where msg_sent = 1 and chat_msg_event = 'x.file'; -- files you offered for sending
select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; -- everything catherine sent related to cats
select * from group_messages where group_name = 'team' and contact = 'alice'; -- all correspondence with alice in #team
-- aggregate your chat data
select contact_or_group, num_messages from (
select contact as contact_or_group, count(1) as num_messages from direct_messages_plain group by contact
union
select group_name as contact_or_group, count(1) as num_messages from group_messages_plain group by group_name
) order by num_messages desc;
```sh
sqlite3 ~/.simplex/simplex_v1_chat.db
```
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):
@@ -327,8 +271,6 @@ Get overnight messages in the morning:
select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt;
```
> **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.
## Roadmap
1. Mobile and desktop apps (in progress).

24
apps/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build/
release/
debug/
/captures
.externalNativeBuild
.cxx
local.properties
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar

3
apps/android/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
apps/android/.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
SimpleX

117
apps/android/.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,117 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
</code_scheme>
</component>

View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

6
apps/android/.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

20
apps/android/.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?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>

18
apps/android/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="app/src/main/res/drawable-v24/ic_launcher_foreground.xml" value="0.2328125" />
<entry key="app/src/main/res/drawable/ic_launcher_background.xml" value="0.2328125" />
<entry key="app/src/main/res/layout/activity_main.xml" value="1.0" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

6
apps/android/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,60 @@
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdk 32
defaultConfig {
applicationId "chat.simplex.app"
minSdk 24
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters 'arm64-v8a'
}
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 {
viewBinding true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

21
apps/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,24 @@
package chat.simplex.app
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("chat.simplex.app", appContext.packageName)
}
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="chat.simplex.app">
<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:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,68 @@
# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html
# Sets the minimum version of CMake required to build the native library.
cmake_minimum_required(VERSION 3.10.2)
# Declares and names the project.
project("app")
# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.
add_library( # Sets the name of the library.
app-lib
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
simplex-api.c)
# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.
find_library( # Sets the name of the path variable.
log-lib
# Specifies the name of the NDK library that
# you want CMake to locate.
log)
find_library( # Sets the name of the path variable.
c-lib
# Specifies the name of the NDK library that
# you want CMake to locate.
c
NAMES libc.so
REQUIRED)
add_library( simplex SHARED IMPORTED )
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsimplex.so)
add_library( support SHARED IMPORTED )
set_target_properties( support PROPERTIES IMPORTED_LOCATION
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.
target_link_libraries( # Specifies the target library.
app-lib
simplex support
# Links the target library to the log library
# included in the NDK.
${log-lib})

View File

@@ -0,0 +1,72 @@
#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_MainActivityKt_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_MainActivityKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
hs_init(NULL, NULL);
setLineBuffering();
}
// from simplex-chat
typedef void* chat_store;
typedef void* controller;
extern chat_store chat_init_store(const char * path);
extern char *chat_get_user(chat_store store);
extern char *chat_create_user(chat_store store, const char *data);
extern controller chat_start(chat_store store);
extern char *chat_send_cmd(controller ctl, const char *cmd);
extern char *chat_recv_msg(controller ctl);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_MainActivityKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
jlong res = (jlong)chat_init_store(_data);
(*env)->ReleaseStringUTFChars(env, datadir, _data);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatGetUser(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_get_user((void*)controller));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatCreateUser(JNIEnv *env, __unused jclass clazz, jlong controller, jstring data) {
const char *_data = (*env)->GetStringUTFChars(env, data, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_create_user((void*)controller, _data));
(*env)->ReleaseStringUTFChars(env, data, _data);
return res;
}
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_MainActivityKt_chatStart(JNIEnv *env, jclass clazz, jlong controller) {
return (jlong)chat_start((void*)controller);
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_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_MainActivityKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
}

View File

@@ -0,0 +1,123 @@
package chat.simplex.app
import android.net.LocalServerSocket
import android.os.Bundle
import android.util.Log
import android.view.inputmethod.EditorInfo
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import java.io.BufferedReader
import java.io.InputStreamReader
import java.lang.ref.WeakReference
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-chat
typealias Store = Long
typealias Controller = Long
external fun chatInit(filesDir: String): Store
external fun chatGetUser(controller: Store) : String
external fun chatCreateUser(controller: Store, data: String) : String
external fun chatStart(controller: Store) : Controller
external fun chatSendCmd(controller: Controller, msg: String) : String
external fun chatRecvMsg(controller: Controller) : String
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
weakActivity = WeakReference(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val store : Store = chatInit(this.applicationContext.filesDir.toString())
// create user if needed
if(chatGetUser(store) == "{}") {
chatCreateUser(store, """
{"displayName": "test", "fullName": "android test"}
""".trimIndent())
}
Log.d("SIMPLEX (user)", chatGetUser(store))
val controller = chatStart(store)
val cmdinput = this.findViewById<AppCompatEditText>(R.id.cmdInput)
cmdinput.setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_SEND -> {
Log.d("SIMPLEX SEND", chatSendCmd(controller, cmdinput.text.toString()))
cmdinput.text?.clear()
true
}
else -> false
}
}
thread(name="receiver") {
val chatlog = FifoQueue<String>(500)
while(true) {
val msg = chatRecvMsg(controller)
Log.d("SIMPLEX RECV", msg)
chatlog.add(msg)
val currentText = chatlog.joinToString("\n")
weakActivity.get()?.runOnUiThread {
val log = weakActivity.get()?.findViewById<TextView>(R.id.chatlog)
val scroll = weakActivity.get()?.findViewById<ScrollView>(R.id.scroller)
log?.text = currentText
scroll?.scrollTo(0, scroll.getChildAt(0).height)
}
}
}
}
companion object {
lateinit var weakActivity : WeakReference<MainActivity>
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)
}
}

View File

@@ -0,0 +1,30 @@
<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>

View File

@@ -0,0 +1,170 @@
<?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>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/cmdInput"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:autofillHints=""
android:ems="10"
android:imeOptions="actionSend"
android:inputType="textPersonName"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:ignore="SpeakableTextPresentCheck" />
<ScrollView
android:id="@+id/scroller"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toTopOf="@+id/cmdInput"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/chatlog"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.SimpleX" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">SimpleX</string>
</resources>

View File

@@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.SimpleX" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@@ -0,0 +1,17 @@
package chat.simplex.app
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

18
apps/android/build.gradle Normal file
View File

@@ -0,0 +1,18 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.0.4"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,21 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

View File

@@ -0,0 +1,6 @@
#Fri Jan 21 23:13:54 GMT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

185
apps/android/gradlew vendored Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"

89
apps/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,10 @@
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
jcenter() // Warning: this repository is going to shut down soon
}
}
rootProject.name = "SimpleX"
include ':app'

67
apps/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,67 @@
## 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/

View File

@@ -0,0 +1,23 @@
{
"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
}
}

View File

@@ -0,0 +1,172 @@
{
"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.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "github_1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "github_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "github_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "github_light_1x.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "github_light_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "github_light_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "logo-2.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "logo-1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "logo.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,76 @@
//
// ContentView.swift
// Shared
//
// Created by Evgeny Poberezkin on 17/01/2022.
//
import SwiftUI
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@State private var showNotificationAlert = false
var body: some View {
if let user = chatModel.currentUser {
ChatListView(user: user)
.onAppear {
do {
try apiStartChat()
chatModel.chats = try apiGetChats()
} catch {
fatalError("Failed to start or load chats: \(error)")
}
ChatReceiver.shared.start()
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
} else {
WelcomeView()
}
}
func notificationAlert() -> Alert {
Alert(
title: Text("Notification are disabled!"),
message: Text("Please open settings to enable"),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
}
},
secondaryButton: .cancel()
)
}
}
final class AlertManager: ObservableObject {
static let shared = AlertManager()
@Published var presentAlert = false
@Published var alertView: Alert?
func showAlert(_ alert: Alert) {
logger.debug("AlertManager.showAlert")
DispatchQueue.main.async {
self.alertView = alert
self.presentAlert = true
}
}
func showAlertMsg(title: String, message: String? = nil) {
if let message = message {
showAlert(Alert(title: Text(title), message: Text(message)))
} else {
showAlert(Alert(title: Text(title)))
}
}
}
//struct ContentView_Previews: PreviewProvider {
// static var previews: some View {
// ContentView(text: "Hello!")
// }
//}

View File

@@ -0,0 +1,80 @@
//
// BGManager.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 08/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import BackgroundTasks
private let receiveTaskId = "chat.simplex.app.receive"
// TCP timeout + 2 sec
private let waitForMessages: TimeInterval = 6
private let bgRefreshInterval: TimeInterval = 450
class BGManager {
static let shared = BGManager()
var chatReceiver: ChatReceiver?
var bgTimer: Timer?
var completed = false
func register() {
logger.debug("BGManager.register")
BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in
self.handleRefresh(task as! BGAppRefreshTask)
}
}
func schedule() {
logger.debug("BGManager.schedule")
let request = BGAppRefreshTaskRequest(identifier: receiveTaskId)
request.earliestBeginDate = Date(timeIntervalSinceNow: bgRefreshInterval)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
logger.error("BGManager.schedule error: \(error.localizedDescription)")
}
}
private func handleRefresh(_ task: BGAppRefreshTask) {
logger.debug("BGManager.handleRefresh")
schedule()
self.completed = false
let completeTask: (String) -> Void = { reason in
logger.debug("BGManager.handleRefresh completeTask: \(reason)")
if !self.completed {
self.completed = true
self.chatReceiver?.stop()
self.chatReceiver = nil
self.bgTimer?.invalidate()
self.bgTimer = nil
task.setTaskCompleted(success: true)
}
}
task.expirationHandler = { completeTask("expirationHandler") }
DispatchQueue.main.async {
initializeChat()
if ChatModel.shared.currentUser == nil {
completeTask("no current user")
return
}
logger.debug("BGManager.handleRefresh: starting chat")
let cr = ChatReceiver()
self.chatReceiver = cr
cr.start()
RunLoop.current.add(Timer(timeInterval: 2, repeats: true) { timer in
logger.debug("BGManager.handleRefresh: timer")
self.bgTimer = timer
if cr.lastMsgTime.distance(to: Date.now) >= waitForMessages {
completeTask("timer (no messages after \(waitForMessages) seconds)")
}
}, forMode: .default)
}
}
}

View File

@@ -0,0 +1,655 @@
//
// ChatModel.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 22/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import Combine
import SwiftUI
final class ChatModel: ObservableObject {
@Published var currentUser: User?
// list of chat "previews"
@Published var chats: [Chat] = []
// current chat
@Published var chatId: String?
@Published var chatItems: [ChatItem] = []
@Published var chatToTop: String?
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: String?
@Published var appOpenUrl: URL?
static let shared = ChatModel()
func hasChat(_ id: String) -> Bool {
chats.first(where: { $0.id == id }) != nil
}
func getChat(_ id: String) -> Chat? {
chats.first(where: { $0.id == id })
}
private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id })
}
func addChat(_ chat: Chat) {
withAnimation {
chats.insert(chat, at: 0)
}
}
func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
chats[i].chatInfo = cInfo
}
}
func updateContact(_ contact: Contact) {
let cInfo = ChatInfo.direct(contact: contact)
if hasChat(contact.id) {
updateChatInfo(cInfo)
} else {
addChat(Chat(chatInfo: cInfo, chatItems: []))
}
}
func updateNetworkStatus(_ contact: Contact, _ status: Chat.NetworkStatus) {
if let ix = getChatIndex(contact.id) {
chats[ix].serverInfo.networkStatus = status
}
}
func replaceChat(_ id: String, _ chat: Chat) {
if let i = getChatIndex(id) {
chats[i] = chat
} else {
// invalid state, correcting
chats.insert(chat, at: 0)
}
}
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update previews
if let i = getChatIndex(cInfo.id) {
chats[i].chatItems = [cItem]
if case .rcvNew = cItem.meta.itemStatus {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1
}
if i > 0 {
if chatId == nil {
withAnimation { popChat_(i) }
} else if chatId == cInfo.id {
chatToTop = cInfo.id
} else {
popChat_(i)
}
}
} else {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
}
// add to current chat
if chatId == cInfo.id {
withAnimation { chatItems.append(cItem) }
if case .rcvNew = cItem.meta.itemStatus {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if self.chatId == cInfo.id {
SimpleX.markChatItemRead(cInfo, cItem)
}
}
}
}
}
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 markChatItemsRead(_ cInfo: ChatInfo) {
// update preview
if let chat = getChat(cInfo.id) {
chat.chatStats = ChatStats()
}
// update current chat
if chatId == cInfo.id {
var i = 0
while i < chatItems.count {
if case .rcvNew = chatItems[i].meta.itemStatus {
chatItems[i].meta.itemStatus = .rcvRead
}
i = i + 1
}
}
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
// update preview
if let i = getChatIndex(cInfo.id) {
chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1
}
// update current chat
if chatId == cInfo.id, let j = chatItems.firstIndex(where: { $0.id == cItem.id }) {
chatItems[j].meta.itemStatus = .rcvRead
}
}
func popChat(_ id: String) {
if let i = getChatIndex(id) {
popChat_(i)
}
}
private func popChat_(_ i: Int) {
let chat = chats.remove(at: i)
chats.insert(chat, at: 0)
}
func removeChat(_ id: String) {
withAnimation {
chats.removeAll(where: { $0.id == id })
}
}
}
struct User: Decodable, NamedChat {
var userId: Int64
var userContactId: Int64
var localDisplayName: ContactName
var profile: Profile
var activeUser: Bool
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
static let sampleData = User(
userId: 1,
userContactId: 1,
localDisplayName: "alice",
profile: Profile.sampleData,
activeUser: true
)
}
typealias ContactName = String
typealias GroupName = String
struct Profile: Codable, NamedChat {
var displayName: String
var fullName: String
static let sampleData = Profile(
displayName: "alice",
fullName: "Alice"
)
}
enum ChatType: String {
case direct = "@"
case group = "#"
case contactRequest = "<@"
}
protocol NamedChat {
var displayName: String { get }
var fullName: String { get }
}
extension NamedChat {
var chatViewName: String {
get { displayName + (fullName == "" || fullName == displayName ? "" : " / \(fullName)") }
}
}
typealias ChatId = String
enum ChatInfo: Identifiable, Decodable, NamedChat {
case direct(contact: Contact)
case group(groupInfo: GroupInfo)
case contactRequest(contactRequest: UserContactRequest)
var localDisplayName: String {
get {
switch self {
case let .direct(contact): return contact.localDisplayName
case let .group(groupInfo): return groupInfo.localDisplayName
case let .contactRequest(contactRequest): return contactRequest.localDisplayName
}
}
}
var displayName: String {
get {
switch self {
case let .direct(contact): return contact.displayName
case let .group(groupInfo): return groupInfo.displayName
case let .contactRequest(contactRequest): return contactRequest.displayName
}
}
}
var fullName: String {
get {
switch self {
case let .direct(contact): return contact.fullName
case let .group(groupInfo): return groupInfo.fullName
case let .contactRequest(contactRequest): return contactRequest.fullName
}
}
}
var id: ChatId {
get {
switch self {
case let .direct(contact): return contact.id
case let .group(groupInfo): return groupInfo.id
case let .contactRequest(contactRequest): return contactRequest.id
}
}
}
var chatType: ChatType {
get {
switch self {
case .direct: return .direct
case .group: return .group
case .contactRequest: return .contactRequest
}
}
}
var apiId: Int64 {
get {
switch self {
case let .direct(contact): return contact.apiId
case let .group(groupInfo): return groupInfo.apiId
case let .contactRequest(contactRequest): return contactRequest.apiId
}
}
}
var ready: Bool {
get {
switch self {
case let .direct(contact): return contact.ready
case let .group(groupInfo): return groupInfo.ready
case let .contactRequest(contactRequest): return contactRequest.ready
}
}
}
var createdAt: Date {
switch self {
case let .direct(contact): return contact.createdAt
case let .group(groupInfo): return groupInfo.createdAt
case let .contactRequest(contactRequest): return contactRequest.createdAt
}
}
struct SampleData {
var direct: ChatInfo
var group: ChatInfo
var contactRequest: ChatInfo
}
static var sampleData: ChatInfo.SampleData = SampleData(
direct: ChatInfo.direct(contact: Contact.sampleData),
group: ChatInfo.group(groupInfo: GroupInfo.sampleData),
contactRequest: ChatInfo.contactRequest(contactRequest: UserContactRequest.sampleData)
)
}
final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats
@Published var serverInfo = ServerInfo(networkStatus: .unknown)
struct ServerInfo: Decodable {
var networkStatus: NetworkStatus
}
enum NetworkStatus: Decodable, Equatable {
case unknown
case connected
case disconnected
case error(String)
var statusString: String {
get {
switch self {
case .connected: return "Server connected"
case let .error(err): return "Connecting server… (error: \(err))"
default: return "Connecting server…"
}
}
}
var statusExplanation: String {
get {
switch self {
case .connected: return "You are connected to the server you use to receve messages from this contact."
case let .error(err): return "Trying to connect to the server you use to receve messages from this contact (error: \(err))."
default: return "Trying to connect to the server you use to receve messages from this contact."
}
}
}
var imageName: String {
get {
switch self {
case .unknown: return "circle.dotted"
case .connected: return "circle.fill"
case .disconnected: return "ellipsis.circle.fill"
case .error: return "exclamationmark.circle.fill"
}
}
}
}
init(_ cData: ChatData) {
self.chatInfo = cData.chatInfo
self.chatItems = cData.chatItems
self.chatStats = cData.chatStats
}
init(chatInfo: ChatInfo, chatItems: [ChatItem] = [], chatStats: ChatStats = ChatStats()) {
self.chatInfo = chatInfo
self.chatItems = chatItems
self.chatStats = chatStats
}
var id: ChatId { get { chatInfo.id } }
}
struct ChatData: Decodable, Identifiable {
var chatInfo: ChatInfo
var chatItems: [ChatItem]
var chatStats: ChatStats
var id: ChatId { get { chatInfo.id } }
}
struct ChatStats: Decodable {
var unreadCount: Int = 0
var minUnreadItemId: Int64 = 0
}
struct Contact: Identifiable, Decodable, NamedChat {
var contactId: Int64
var localDisplayName: ContactName
var profile: Profile
var activeConn: Connection
var viaGroup: Int64?
var createdAt: Date
var id: ChatId { get { "@\(contactId)" } }
var apiId: Int64 { get { contactId } }
var ready: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
static let sampleData = Contact(
contactId: 1,
localDisplayName: "alice",
profile: Profile.sampleData,
activeConn: Connection.sampleData,
createdAt: .now
)
}
struct Connection: Decodable {
var connStatus: String
static let sampleData = Connection(connStatus: "ready")
}
struct UserContactRequest: Decodable, NamedChat {
var contactRequestId: Int64
var localDisplayName: ContactName
var profile: Profile
var createdAt: Date
var id: ChatId { get { "<@\(contactRequestId)" } }
var apiId: Int64 { get { contactRequestId } }
var ready: Bool { get { true } }
var displayName: String { get { profile.displayName } }
var fullName: String { get { profile.fullName } }
static let sampleData = UserContactRequest(
contactRequestId: 1,
localDisplayName: "alice",
profile: Profile.sampleData,
createdAt: .now
)
}
struct GroupInfo: Identifiable, Decodable, NamedChat {
var groupId: Int64
var localDisplayName: GroupName
var groupProfile: GroupProfile
var createdAt: Date
var id: ChatId { get { "#\(groupId)" } }
var apiId: Int64 { get { groupId } }
var ready: Bool { get { true } }
var displayName: String { get { groupProfile.displayName } }
var fullName: String { get { groupProfile.fullName } }
static let sampleData = GroupInfo(
groupId: 1,
localDisplayName: "team",
groupProfile: GroupProfile.sampleData,
createdAt: .now
)
}
struct GroupProfile: Codable, NamedChat {
var displayName: String
var fullName: String
static let sampleData = GroupProfile(
displayName: "team",
fullName: "My Team"
)
}
struct GroupMember: Decodable {
var groupMemberId: Int64
var memberId: String
// var memberRole: GroupMemberRole
// var memberCategory: GroupMemberCategory
// var memberStatus: GroupMemberStatus
// var invitedBy: InvitedBy
var localDisplayName: ContactName
var memberProfile: Profile
var memberContactId: Int64?
// var activeConn: Connection?
static let sampleData = GroupMember(
groupMemberId: 1,
memberId: "abcd",
localDisplayName: "alice",
memberProfile: Profile.sampleData,
memberContactId: 1
)
}
struct AChatItem: Decodable {
var chatInfo: ChatInfo
var chatItem: ChatItem
}
struct ChatItem: Identifiable, Decodable {
var chatDir: CIDirection
var meta: CIMeta
var content: CIContent
var id: Int64 { get { meta.itemId } }
var timestampText: String { get { meta.timestampText } }
func isRcvNew() -> Bool {
if case .rcvNew = meta.itemStatus { return true }
return false
}
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> ChatItem {
ChatItem(
chatDir: dir,
meta: CIMeta.getSample(id, ts, text, status),
content: .sndMsgContent(msgContent: .text(text))
)
}
}
enum CIDirection: Decodable {
case directSnd
case directRcv
case groupSnd
case groupRcv(groupMember: GroupMember)
var sent: Bool {
get {
switch self {
case .directSnd: return true
case .directRcv: return false
case .groupSnd: return true
case .groupRcv: return false
}
}
}
}
struct CIMeta: Decodable {
var itemId: Int64
var itemTs: Date
var itemText: String
var itemStatus: CIStatus
var createdAt: Date
var timestampText: String { get { SimpleX.timestampText(itemTs) } }
static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta {
CIMeta(
itemId: id,
itemTs: ts,
itemText: text,
itemStatus: status,
createdAt: ts
)
}
}
func timestampText(_ date: Date) -> String {
date.formatted(date: .omitted, time: .shortened)
}
enum CIStatus: Decodable {
case sndNew
case sndSent
case sndErrorAuth
case sndError(agentError: AgentErrorType)
case rcvNew
case rcvRead
}
enum CIContent: Decodable {
case sndMsgContent(msgContent: MsgContent)
case rcvMsgContent(msgContent: MsgContent)
case sndFileInvitation(fileId: Int64, filePath: String)
case rcvFileInvitation(rcvFileTransfer: RcvFileTransfer)
var text: String {
get {
switch self {
case let .sndMsgContent(mc): return mc.text
case let .rcvMsgContent(mc): return mc.text
case .sndFileInvitation: return "sending files is not supported yet"
case .rcvFileInvitation: return "receiving files is not supported yet"
}
}
}
}
struct RcvFileTransfer: Decodable {
}
enum MsgContent {
case text(String)
case unknown(type: String, text: String)
case invalid(error: String)
var text: String {
get {
switch self {
case let .text(text): return text
case let .unknown(_, text): return text
case .invalid: return "invalid"
}
}
}
var cmdString: String {
get {
switch self {
case let .text(text): return "text \(text)"
default: return ""
}
}
}
enum CodingKeys: String, CodingKey {
case type
case text
}
}
extension MsgContent: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
let type = try container.decode(String.self, forKey: CodingKeys.type)
switch type {
case "text":
let text = try container.decode(String.self, forKey: CodingKeys.text)
self = .text(text)
default:
let text = try? container.decode(String.self, forKey: CodingKeys.text)
self = .unknown(type: type, text: text ?? "unknown message format")
}
} catch {
self = .invalid(error: String(describing: error))
}
}
}

View File

@@ -0,0 +1,38 @@
//
// JSON.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
func getJSONDecoder() -> JSONDecoder {
let jd = JSONDecoder()
let fracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ")
let noFracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ssZZZZZ")
jd.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = fracSeconds.date(from: string) ?? noFracSeconds.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
}
return jd
}
func getJSONEncoder() -> JSONEncoder {
let je = JSONEncoder()
je.dateEncodingStrategy = .iso8601
return je
}
private func getDateFormatter(_ format: String) -> DateFormatter {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = format
df.timeZone = TimeZone(secondsFromGMT: 0)
return df
}

View File

@@ -0,0 +1,195 @@
//
// NtfManager.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 08/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import UserNotifications
import UIKit
let ntfActionAccept = "NTF_ACT_ACCEPT"
let ntfCategoryContactRequest = "NTF_CAT_CONTACT_REQUEST"
let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
let appNotificationId = "chat.simplex.app.notification"
private let ntfTimeInterval: TimeInterval = 1
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
static let shared = NtfManager()
private var granted = false
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
// Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive")
let content = response.notification.request.content
let chatModel = ChatModel.shared
if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept,
let chatId = content.userInfo["chatId"] as? String,
case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
acceptContactRequest(contactRequest)
} else {
chatModel.chatId = content.targetContentIdentifier
}
handler()
}
// Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler handler: (UNNotificationPresentationOptions) -> Void) {
logger.debug("NtfManager.userNotificationCenter: willPresent")
handler(presentationOptions(notification.request.content))
}
private func presentationOptions(_ content: UNNotificationContent) -> UNNotificationPresentationOptions {
let model = ChatModel.shared
if UIApplication.shared.applicationState == .active {
switch content.categoryIdentifier {
case ntfCategoryMessageReceived:
if model.chatId == nil {
// in the chat list
return recentInTheSameChat(content) ? [] : [.sound, .list]
} else if model.chatId == content.targetContentIdentifier {
// in the current chat
return recentInTheSameChat(content) ? [] : [.sound, .list]
} else {
// in another chat
return recentInTheSameChat(content) ? [.banner, .list] : [.sound, .banner, .list]
}
default: return [.sound, .banner, .list]
}
} else {
return [.sound, .banner, .list]
}
}
private func recentInTheSameChat(_ content: UNNotificationContent) -> Bool {
let now = Date.now
if let chatId = content.targetContentIdentifier {
var res: Bool = false
if let t = prevNtfTime[chatId] { res = t.distance(to: now) < 30 }
prevNtfTime[chatId] = now
return res
}
return false
}
func registerCategories() {
logger.debug("NtfManager.registerCategories")
UNUserNotificationCenter.current().setNotificationCategories([
UNNotificationCategory(
identifier: ntfCategoryContactRequest,
actions: [UNNotificationAction(
identifier: ntfActionAccept,
title: "Accept"
)],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "New contact request"
),
UNNotificationCategory(
identifier: ntfCategoryContactConnected,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "Contact is connected"
),
UNNotificationCategory(
identifier: ntfCategoryMessageReceived,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "New message"
)
])
}
func requestAuthorization(onDeny handler: (()-> Void)? = nil) {
logger.debug("NtfManager.requestAuthorization")
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .denied:
if let handler = handler { handler() }
return
case .authorized:
self.granted = true
default:
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error = error {
logger.error("NtfManager.requestAuthorization error \(error.localizedDescription)")
} else {
self.granted = granted
}
}
}
}
center.delegate = self
}
func notifyContactRequest(_ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
addNotification(
categoryIdentifier: ntfCategoryContactRequest,
title: "\(contactRequest.displayName) wants to connect!",
body: "Accept contact request from \(contactRequest.chatViewName)?",
targetContentIdentifier: nil,
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId]
)
}
func notifyContactConnected(_ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
addNotification(
categoryIdentifier: ntfCategoryContactConnected,
title: "\(contact.displayName) is connected!",
body: "You can now send messages to \(contact.chatViewName)",
targetContentIdentifier: contact.id
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
)
}
func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
addNotification(
categoryIdentifier: ntfCategoryMessageReceived,
title: "\(cInfo.chatViewName):",
body: cItem.content.text,
targetContentIdentifier: cInfo.id
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
)
}
private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) {
if !granted { return }
let content = UNMutableNotificationContent()
content.categoryIdentifier = categoryIdentifier
content.title = title
if let s = subtitle { content.subtitle = s }
if let s = body { content.body = s }
content.targetContentIdentifier = targetContentIdentifier
content.userInfo = userInfo
content.sound = .default
// content.interruptionLevel = .active
// content.relevanceScore = 0.5 // 0-1
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false)
let request = UNNotificationRequest(identifier: appNotificationId, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error { logger.error("addNotification error: \(error.localizedDescription)") }
}
}
func removeNotifications(_ ids : [String]){
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
}
}

View File

@@ -0,0 +1,702 @@
//
// ChatAPI.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 27/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import UIKit
import Dispatch
import BackgroundTasks
private var chatController: chat_ctrl?
private let jsonDecoder = getJSONDecoder()
private let jsonEncoder = getJSONEncoder()
enum ChatCommand {
case showActiveUser
case createActiveUser(profile: Profile)
case startChat
case apiGetChats
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
case updateProfile(profile: Profile)
case createMyAddress
case deleteMyAddress
case showMyAddress
case apiAcceptContact(contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case string(String)
var cmdString: String {
get {
switch self {
case .showActiveUser: return "/u"
case let .createActiveUser(profile): return "/u \(profile.displayName) \(profile.fullName)"
case .startChat: return "/_start"
case .apiGetChats: return "/_get chats"
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)"
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
case let .updateProfile(profile): return "/profile \(profile.displayName) \(profile.fullName)"
case .createMyAddress: return "/address"
case .deleteMyAddress: return "/delete_address"
case .showMyAddress: return "/show_address"
case let .apiAcceptContact(contactReqId): return "/_accept \(contactReqId)"
case let .apiRejectContact(contactReqId): return "/_reject \(contactReqId)"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .string(str): return str
}
}
}
var cmdType: String {
get {
switch self {
case .showActiveUser: return "showActiveUser"
case .createActiveUser: return "createActiveUser"
case .startChat: return "startChat"
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
case .updateProfile: return "updateProfile"
case .createMyAddress: return "createMyAddress"
case .deleteMyAddress: return "deleteMyAddress"
case .showMyAddress: return "showMyAddress"
case .apiAcceptContact: return "apiAcceptContact"
case .apiRejectContact: return "apiRejectContact"
case .apiChatRead: return "apiChatRead"
case .string: return "console command"
}
}
}
func ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
}
}
struct APIResponse: Decodable {
var resp: ChatResponse
}
enum ChatResponse: Decodable, Error {
case response(type: String, json: String)
case activeUser(user: User)
case chatStarted
case apiChats(chats: [ChatData])
case apiChat(chat: ChatData)
case invitation(connReqInvitation: String)
case sentConfirmation
case sentInvitation
case contactDeleted(contact: Contact)
case userProfileNoChange
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
case userContactLink(connReqContact: String)
case userContactLinkCreated(connReqContact: String)
case userContactLinkDeleted
case contactConnected(contact: Contact)
case receivedContactRequest(contactRequest: UserContactRequest)
case acceptingContactRequest(contact: Contact)
case contactRequestRejected
case contactUpdated(toContact: Contact)
case contactSubscribed(contact: Contact)
case contactDisconnected(contact: Contact)
case contactSubError(contact: Contact, chatError: ChatError)
case groupSubscribed(groupInfo: GroupInfo)
case groupEmpty(groupInfo: GroupInfo)
case userContactLinkSubscribed
case newChatItem(chatItem: AChatItem)
case chatItemUpdated(chatItem: AChatItem)
case cmdOk
case chatCmdError(chatError: ChatError)
case chatError(chatError: ChatError)
var responseType: String {
get {
switch self {
case let .response(type, _): return "* \(type)"
case .activeUser: return "activeUser"
case .chatStarted: return "chatStarted"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .invitation: return "invitation"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
case .contactDeleted: return "contactDeleted"
case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileNoChange"
case .userContactLink: return "userContactLink"
case .userContactLinkCreated: return "userContactLinkCreated"
case .userContactLinkDeleted: return "userContactLinkDeleted"
case .contactConnected: return "contactConnected"
case .receivedContactRequest: return "receivedContactRequest"
case .acceptingContactRequest: return "acceptingContactRequest"
case .contactRequestRejected: return "contactRequestRejected"
case .contactUpdated: return "contactUpdated"
case .contactSubscribed: return "contactSubscribed"
case .contactDisconnected: return "contactDisconnected"
case .contactSubError: return "contactSubError"
case .groupSubscribed: return "groupSubscribed"
case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItem: return "newChatItem"
case .chatItemUpdated: return "chatItemUpdated"
case .cmdOk: return "cmdOk"
case .chatCmdError: return "chatCmdError"
case .chatError: return "chatError"
}
}
}
var details: String {
get {
switch self {
case let .response(_, json): return json
case let .activeUser(user): return String(describing: user)
case .chatStarted: return noDetails
case let .apiChats(chats): return String(describing: chats)
case let .apiChat(chat): return String(describing: chat)
case let .invitation(connReqInvitation): return connReqInvitation
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
case let .contactDeleted(contact): return String(describing: contact)
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
case let .userContactLink(connReq): return connReq
case let .userContactLinkCreated(connReq): return connReq
case .userContactLinkDeleted: return noDetails
case let .contactConnected(contact): return String(describing: contact)
case let .receivedContactRequest(contactRequest): return String(describing: contactRequest)
case let .acceptingContactRequest(contact): return String(describing: contact)
case .contactRequestRejected: return noDetails
case let .contactUpdated(toContact): return String(describing: toContact)
case let .contactSubscribed(contact): return String(describing: contact)
case let .contactDisconnected(contact): return String(describing: contact)
case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
case let .groupEmpty(groupInfo): return String(describing: groupInfo)
case .userContactLinkSubscribed: return noDetails
case let .newChatItem(chatItem): return String(describing: chatItem)
case let .chatItemUpdated(chatItem): return String(describing: chatItem)
case .cmdOk: return noDetails
case let .chatCmdError(chatError): return String(describing: chatError)
case let .chatError(chatError): return String(describing: chatError)
}
}
}
private var noDetails: String { get { "\(responseType): no details" } }
}
enum TerminalItem: Identifiable {
case cmd(Date, ChatCommand)
case resp(Date, ChatResponse)
var id: Date {
get {
switch self {
case let .cmd(id, _): return id
case let .resp(id, _): return id
}
}
}
var label: String {
get {
switch self {
case let .cmd(_, cmd): return "> \(cmd.cmdString.prefix(30))"
case let .resp(_, resp): return "< \(resp.responseType)"
}
}
}
var details: String {
get {
switch self {
case let .cmd(_, cmd): return cmd.cmdString
case let .resp(_, resp): return resp.details
}
}
}
}
func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
var c = cmd.cmdString.cString(using: .utf8)!
logger.debug("chatSendCmd \(cmd.cmdType)")
let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
DispatchQueue.main.async {
ChatModel.shared.terminalItems.append(.cmd(.now, cmd))
ChatModel.shared.terminalItems.append(.resp(.now, resp))
}
return resp
}
func chatRecvMsg() throws -> ChatResponse {
chatResponse(chat_recv_msg(getChatCtrl())!)
}
func apiGetActiveUser() throws -> User? {
let _ = getChatCtrl()
let r = try chatSendCmd(.showActiveUser)
switch r {
case let .activeUser(user): return user
case .chatCmdError(.error(.noActiveUser)): return nil
default: throw r
}
}
func apiCreateActiveUser(_ p: Profile) throws -> User {
let r = try chatSendCmd(.createActiveUser(profile: p))
if case let .activeUser(user) = r { return user }
throw r
}
func apiStartChat() throws {
let r = try chatSendCmd(.startChat)
if case .chatStarted = r { return }
throw r
}
func apiGetChats() throws -> [Chat] {
let r = try chatSendCmd(.apiGetChats)
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
throw r
}
func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
let r = try chatSendCmd(.apiGetChat(type: type, id: id))
if case let .apiChat(chat) = r { return Chat.init(chat) }
throw r
}
func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) throws -> ChatItem {
let r = try chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
if case let .newChatItem(aChatItem) = r { return aChatItem.chatItem }
throw r
}
func apiAddContact() throws -> String {
let r = try chatSendCmd(.addContact)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
throw r
}
func apiConnect(connReq: String) throws {
let r = try chatSendCmd(.connect(connReq: connReq))
switch r {
case .sentConfirmation: return
case .sentInvitation: return
default: throw r
}
}
func apiDeleteChat(type: ChatType, id: Int64) throws {
let r = try chatSendCmd(.apiDeleteChat(type: type, id: id))
if case .contactDeleted = r { return }
throw r
}
func apiUpdateProfile(profile: Profile) throws -> Profile? {
let r = try chatSendCmd(.updateProfile(profile: profile))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(_, toProfile): return toProfile
default: throw r
}
}
func apiCreateUserAddress() throws -> String {
let r = try chatSendCmd(.createMyAddress)
if case let .userContactLinkCreated(connReq) = r { return connReq }
throw r
}
func apiDeleteUserAddress() throws {
let r = try chatSendCmd(.deleteMyAddress)
if case .userContactLinkDeleted = r { return }
throw r
}
func apiGetUserAddress() throws -> String? {
let r = try chatSendCmd(.showMyAddress)
switch r {
case let .userContactLink(connReq):
return connReq
case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)):
return nil
default: throw r
}
}
func apiAcceptContactRequest(contactReqId: Int64) throws -> Contact {
let r = try chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
if case let .acceptingContactRequest(contact) = r { return contact }
throw r
}
func apiRejectContactRequest(contactReqId: Int64) throws {
let r = try chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
if case .contactRequestRejected = r { return }
throw r
}
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) throws {
let r = try chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
if case .cmdOk = r { return }
throw r
}
func acceptContactRequest(_ contactRequest: UserContactRequest) {
do {
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
ChatModel.shared.replaceChat(contactRequest.id, chat)
} catch let error {
logger.error("acceptContactRequest error: \(error.localizedDescription)")
}
}
func rejectContactRequest(_ contactRequest: UserContactRequest) {
do {
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
ChatModel.shared.removeChat(contactRequest.id)
} catch let error {
logger.error("rejectContactRequest: \(error.localizedDescription)")
}
}
func markChatRead(_ chat: Chat) {
do {
let minItemId = chat.chatStats.minUnreadItemId
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
ChatModel.shared.markChatItemsRead(cInfo)
} catch {
logger.error("markChatRead apiChatRead error: \(error.localizedDescription)")
}
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
do {
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
ChatModel.shared.markChatItemRead(cInfo, cItem)
} catch {
logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)")
}
}
func initializeChat() {
do {
ChatModel.shared.currentUser = try apiGetActiveUser()
} catch {
fatalError("Failed to initialize chat controller or database: \(error)")
}
}
class ChatReceiver {
private var receiveLoop: DispatchWorkItem?
private var receiveMessages = true
private var _lastMsgTime = Date.now
static let shared = ChatReceiver()
var lastMsgTime: Date { get { _lastMsgTime } }
func start() {
logger.debug("ChatReceiver.start")
receiveMessages = true
_lastMsgTime = .now
if receiveLoop != nil { return }
let loop = DispatchWorkItem(qos: .default, flags: []) {
while self.receiveMessages {
do {
processReceivedMsg(try chatRecvMsg())
self._lastMsgTime = .now
} catch {
logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)")
}
}
}
receiveLoop = loop
DispatchQueue.global().async(execute: loop)
}
func stop() {
logger.debug("ChatReceiver.stop")
receiveMessages = false
receiveLoop?.cancel()
receiveLoop = nil
}
}
func processReceivedMsg(_ res: ChatResponse) {
let chatModel = ChatModel.shared
DispatchQueue.main.async {
chatModel.terminalItems.append(.resp(.now, res))
logger.debug("processReceivedMsg: \(res.responseType)")
switch res {
case let .contactConnected(contact):
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, .connected)
NtfManager.shared.notifyContactConnected(contact)
case let .receivedContactRequest(contactRequest):
chatModel.addChat(Chat(
chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
chatItems: []
))
NtfManager.shared.notifyContactRequest(contactRequest)
case let .contactUpdated(toContact):
let cInfo = ChatInfo.direct(contact: toContact)
if chatModel.hasChat(toContact.id) {
chatModel.updateChatInfo(cInfo)
}
case let .contactSubscribed(contact):
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, .connected)
case let .contactDisconnected(contact):
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, .disconnected)
case let .contactSubError(contact, chatError):
chatModel.updateContact(contact)
var err: String
switch chatError {
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError)
}
chatModel.updateNetworkStatus(contact, .error(err))
case let .newChatItem(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.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)")
}
}
}
private struct UserResponse: Decodable {
var user: User?
var error: String?
}
private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
// TODO is there a way to do it without copying the data? e.g:
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber?
do {
let r = try jsonDecoder.decode(APIResponse.self, from: d)
return r.resp
} catch {
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
}
var type: String?
var json: String?
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
if let j1 = j["resp"] as? NSDictionary, j1.count == 1 {
type = j1.allKeys[0] as? String
}
json = prettyJSON(j)
}
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
}
func prettyJSON(_ obj: NSDictionary) -> String? {
if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) {
return String(decoding: d, as: UTF8.self)
}
return nil
}
private func getChatCtrl() -> chat_ctrl {
if let controller = chatController { return controller }
let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1"
var cstr = dataDir.cString(using: .utf8)!
chatController = chat_init(&cstr)
return chatController!
}
private func decodeCJSON<T: Decodable>(_ cjson: UnsafePointer<CChar>) -> T? {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
return try? jsonDecoder.decode(T.self, from: d)
}
private func getJSONObject(_ cjson: UnsafePointer<CChar>) -> NSDictionary? {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
return try? JSONSerialization.jsonObject(with: d) as? NSDictionary
}
private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
let data = try! jsonEncoder.encode(value)
let str = String(decoding: data, as: UTF8.self)
return str.cString(using: .utf8)!
}
enum ChatError: Decodable {
case error(errorType: ChatErrorType)
case errorAgent(agentError: AgentErrorType)
case errorStore(storeError: StoreError)
}
enum ChatErrorType: Decodable {
case noActiveUser
case activeUserExists
case chatNotStarted
case invalidConnReq
case invalidChatMessage(message: String)
case contactNotReady(contact: Contact)
case contactGroups(contact: Contact, groupNames: [GroupName])
case groupUserRole
case groupContactRole(contactName: ContactName)
case groupDuplicateMember(contactName: ContactName)
case groupDuplicateMemberId
case groupNotJoined(groupInfo: GroupInfo)
case groupMemberNotActive
case groupMemberUserRemoved
case groupMemberNotFound(contactName: ContactName)
case groupMemberIntroNotFound(contactName: ContactName)
case groupCantResendInvitation(groupInfo: GroupInfo, contactName: ContactName)
case groupInternal(message: String)
case fileNotFound(message: String)
case fileAlreadyReceiving(message: String)
case fileAlreadyExists(filePath: String)
case fileRead(filePath: String, message: String)
case fileWrite(filePath: String, message: String)
case fileSend(fileId: Int64, agentError: String)
case fileRcvChunk(message: String)
case fileInternal(message: String)
case agentVersion
case commandError(message: String)
}
enum StoreError: Decodable {
case duplicateName
case contactNotFound(contactId: Int64)
case contactNotFoundByName(contactName: ContactName)
case contactNotReady(contactName: ContactName)
case duplicateContactLink
case userContactLinkNotFound
case contactRequestNotFound(contactRequestId: Int64)
case contactRequestNotFoundByName(contactName: ContactName)
case groupNotFound(groupId: Int64)
case groupNotFoundByName(groupName: GroupName)
case groupWithoutUser
case duplicateGroupMember
case groupAlreadyJoined
case groupInvitationNotFound
case sndFileNotFound(fileId: Int64)
case sndFileInvalid(fileId: Int64)
case rcvFileNotFound(fileId: Int64)
case fileNotFound(fileId: Int64)
case rcvFileInvalid(fileId: Int64)
case connectionNotFound(agentConnId: String)
case introNotFound
case uniqueID
case internalError(message: String)
case noMsgDelivery(connId: Int64, agentMsgId: String)
case badChatItem(itemId: Int64)
case chatItemNotFound(itemId: Int64)
}
enum AgentErrorType: Decodable {
case CMD(cmdErr: CommandErrorType)
case CONN(connErr: ConnectionErrorType)
case SMP(smpErr: SMPErrorType)
case BROKER(brokerErr: BrokerErrorType)
case AGENT(agentErr: SMPAgentError)
case INTERNAL(internalErr: String)
}
enum CommandErrorType: Decodable {
case PROHIBITED
case SYNTAX
case NO_CONN
case SIZE
case LARGE
}
enum ConnectionErrorType: Decodable {
case NOT_FOUND
case DUPLICATE
case SIMPLEX
case NOT_ACCEPTED
case NOT_AVAILABLE
}
enum BrokerErrorType: Decodable {
case RESPONSE(smpErr: SMPErrorType)
case UNEXPECTED
case NETWORK
case TRANSPORT(transportErr: SMPTransportError)
case TIMEOUT
}
enum SMPErrorType: Decodable {
case BLOCK
case SESSION
case CMD(cmdErr: SMPCommandError)
case AUTH
case QUOTA
case NO_MSG
case LARGE_MSG
case INTERNAL
}
enum SMPCommandError: Decodable {
case UNKNOWN
case SYNTAX
case NO_AUTH
case HAS_AUTH
case NO_QUEUE
}
enum SMPTransportError: Decodable {
case TEBadBlock
case TELargeMsg
case TEBadSession
case TEHandshake(handshakeErr: SMPHandshakeError)
}
enum SMPHandshakeError: Decodable {
case PARSE
case VERSION
case IDENTITY
}
enum SMPAgentError: Decodable {
case A_MESSAGE
case A_PROHIBITED
case A_VERSION
case A_ENCRYPTION
}

View File

@@ -0,0 +1,32 @@
import Foundation
var greeting = "Hello, playground"
let jsonEncoder = JSONEncoder()
//jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!)
var a = [1, 2, 3]
a.removeAll(where: { $0 == 1} )
print(a)
let input = "This is a test with the привет 🙂 URL https://www.hackingwithswift.com to be detected."
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector.matches(in: input, options: [], range: NSRange(location: 0, length: input.count))
print(matches)
for match in matches {
guard let range = Range(match.range, in: input) else { continue }
let url = input[range]
print(url)
}
let r = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
print(r.firstMatch(in: "+44(0)7448-736-790", options: [], range: NSRange(location: 0, length: "+44(0)7448-736-790".count)) == nil)
let action: NtfAction? = NtfAction(rawValue: "NTF_ACT_ACCEPT")

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
<timeline fileName='timeline.xctimeline'/>
</playground>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<Timeline
version = "3.0">
<TimelineItems>
<LoggerValueHistoryTimelineItem
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&amp;CharacterRangeLoc=91&amp;EndingColumnNumber=0&amp;EndingLineNumber=7&amp;StartingColumnNumber=3&amp;StartingLineNumber=6&amp;Timestamp=666087303.155273"
selectedRepresentationIndex = "0"
shouldTrackSuperviewWidth = "NO">
</LoggerValueHistoryTimelineItem>
</TimelineItems>
</Timeline>

View File

@@ -0,0 +1,11 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
extern void hs_init(int argc, char **argv[]);
typedef void* chat_ctrl;
extern chat_ctrl chat_init(char *path);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);

View File

@@ -0,0 +1,11 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
extern void hs_init(int argc, char **argv[]);
typedef void* chat_ctrl;
extern chat_ctrl chat_init(char *path);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);

View File

@@ -0,0 +1,42 @@
//
// SimpleXApp.swift
// Shared
//
// Created by Evgeny Poberezkin on 17/01/2022.
//
import SwiftUI
import OSLog
let logger = Logger()
@main
struct SimpleXApp: App {
@StateObject private var chatModel = ChatModel.shared
@Environment(\.scenePhase) var scenePhase
init() {
hs_init(0, nil)
BGManager.shared.register()
NtfManager.shared.registerCategories()
}
var body: some Scene {
return WindowGroup {
ContentView()
.environmentObject(chatModel)
.onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)")
chatModel.appOpenUrl = url
}
.onAppear() {
initializeChat()
}
.onChange(of: scenePhase) { phase in
if phase == .background {
BGManager.shared.schedule()
}
}
}
}
}

View File

@@ -0,0 +1,43 @@
//
// ChatInfoToolbar.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 11/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let chatImageColorLight = Color(red: 0.9, green: 0.9, blue: 0.9)
private let chatImageColorDark = Color(red: 0.2, green: 0.2, blue: 0.2 )
struct ChatInfoToolbar: View {
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var body: some View {
let cInfo = chat.chatInfo
return HStack {
ChatInfoImage(
chat: chat,
color: colorScheme == .dark
? chatImageColorDark
: chatImageColorLight
)
.frame(width: 32, height: 32)
.padding(.trailing, 4)
VStack {
Text(cInfo.displayName).font(.headline)
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
Text(cInfo.fullName).font(.subheadline)
}
}
}
.foregroundColor(.primary)
}
}
struct ChatInfoToolbar_Previews: PreviewProvider {
static var previews: some View {
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
}
}

View File

@@ -0,0 +1,84 @@
//
// ChatInfoView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 05/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var chat: Chat
@Binding var showChatInfo: Bool
var body: some View {
VStack{
ChatInfoImage(chat: chat)
.frame(width: 192, height: 192)
.padding(.top, 48)
.padding()
Text(chat.chatInfo.localDisplayName).font(.largeTitle)
.padding(.bottom, 2)
Text(chat.chatInfo.fullName).font(.title)
.padding(.bottom)
if case let .direct(contact) = chat.chatInfo {
VStack {
HStack {
serverImage()
Text(chat.serverInfo.networkStatus.statusString)
.foregroundColor(.primary)
}
Text(chat.serverInfo.networkStatus.statusExplanation)
.font(.subheadline)
.multilineTextAlignment(.center)
.padding(.horizontal, 64)
.padding(.vertical, 8)
Spacer()
Button(role: .destructive) {
alertManager.showAlert(deleteContactAlert(contact))
} label: {
Label("Delete contact", systemImage: "trash")
}
.padding()
}
}
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
func serverImage() -> some View {
let status = chat.serverInfo.networkStatus
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : .secondary)
}
private func deleteContactAlert(_ contact: Contact) -> Alert {
Alert(
title: Text("Delete contact?"),
message: Text("Contact and all messages will be deleted"),
primaryButton: .destructive(Text("Delete")) {
do {
try apiDeleteChat(type: .direct, id: contact.apiId)
chatModel.removeChat(contact.id)
showChatInfo = false
} catch let error {
logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
}
},
secondaryButton: .cancel()
)
}
}
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
@State var showChatInfo = true
return ChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []), showChatInfo: $showChatInfo)
}
}

View File

@@ -0,0 +1,47 @@
//
// CIMetaView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 11/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct CIMetaView: View {
var chatItem: ChatItem
var body: some View {
HStack(alignment: .center, spacing: 4) {
switch chatItem.meta.itemStatus {
case .sndSent:
statusImage("checkmark", .secondary)
case .sndErrorAuth:
statusImage("multiply", .red)
case .sndError:
statusImage("exclamationmark.triangle.fill", .yellow)
case .rcvNew:
statusImage("circlebadge.fill", Color.accentColor)
default: EmptyView()
}
Text(chatItem.timestampText)
.font(.caption)
.foregroundColor(.secondary)
}
}
private func statusImage(_ systemName: String, _ color: Color) -> some View {
Image(systemName: systemName)
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundColor(color)
.frame(maxHeight: 8)
}
}
struct CIMetaView_Previews: PreviewProvider {
static var previews: some View {
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent))
}
}

View File

@@ -0,0 +1,42 @@
//
// EmojiItemView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 04/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct EmojiItemView: View {
var chatItem: ChatItem
var body: some View {
let sent = chatItem.chatDir.sent
let s = chatItem.content.text.trimmingCharacters(in: .whitespaces)
VStack(spacing: 1) {
Text(s)
.font(s.count < 4 ? largeEmojiFont : mediumEmojiFont)
.padding(.top, 8)
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
CIMetaView(chatItem: chatItem)
.padding(.bottom, 8)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
}
.padding(.horizontal)
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
}
}
struct EmojiItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent))
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
}
.previewLayout(.fixed(width: 360, height: 70))
}
}

View File

@@ -0,0 +1,149 @@
//
// TextItemView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 04/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$", options: .caseInsensitive)
private let phoneRegex = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
private let linkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
struct TextItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var width: CGFloat
private let codeFont = Font.custom("Courier", size: UIFont.preferredFont(forTextStyle: .body).pointSize)
var body: some View {
let sent = chatItem.chatDir.sent
let maxWidth = width * 0.78
return ZStack(alignment: .bottomTrailing) {
(messageText(chatItem) + reserveSpaceForMeta(chatItem.timestampText))
.padding(.vertical, 6)
.padding(.horizontal, 12)
.frame(minWidth: 0, alignment: .leading)
.textSelection(.enabled)
CIMetaView(chatItem: chatItem)
.padding(.trailing, 12)
.padding(.bottom, 6)
}
.background(
sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
)
.cornerRadius(18)
.padding(.horizontal)
.frame(
maxWidth: maxWidth,
maxHeight: .infinity,
alignment: sent ? .trailing : .leading
)
.onTapGesture {
switch chatItem.meta.itemStatus {
case .sndErrorAuth: msgDeliveryError("Most likely this contact has deleted the connection with you.")
case let .sndError(agentError): msgDeliveryError("Unexpected error: \(String(describing: agentError))")
default: return
}
}
}
private func messageText(_ chatItem: ChatItem) -> Text {
let s = chatItem.content.text
var res: Text
if s == "" {
res = Text("")
} else {
let parts = s.split(separator: " ")
res = wordToText(parts[0])
var i = 1
while i < parts.count {
res = res + Text(" ") + wordToText(parts[i])
i = i + 1
}
}
if case let .groupRcv(groupMember) = chatItem.chatDir {
let member = Text(groupMember.memberProfile.displayName).font(.headline)
return member + Text(": ") + res
} else {
return res
}
}
private func reserveSpaceForMeta(_ meta: String) -> Text {
Text(" \(meta)")
.font(.caption)
.foregroundColor(.clear)
}
private func wordToText(_ s: String.SubSequence) -> Text {
let str = String(s)
switch true {
case s.starts(with: "http://") || s.starts(with: "https://"):
return linkText(str, prefix: "")
case match(str, emailRegex):
return linkText(str, prefix: "mailto:")
case match(str, phoneRegex):
return linkText(str, prefix: "tel:")
default:
if (s.count > 1) {
switch true {
case s.first == "*" && s.last == "*": return mdText(s).bold()
case s.first == "_" && s.last == "_": return mdText(s).italic()
case s.first == "+" && s.last == "+": return mdText(s).underline()
case s.first == "~" && s.last == "~": return mdText(s).strikethrough()
default: return Text(s)
}
} else {
return Text(s)
}
}
}
private func match(_ s: String, _ regex: NSRegularExpression) -> Bool {
regex.firstMatch(in: s, options: [], range: NSRange(location: 0, length: s.count)) != nil
}
private func linkText(_ s: String, prefix: String) -> Text {
Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + s) as Any,
.foregroundColor: linkColor as Any
]))).underline()
}
private func mdText(_ s: String.SubSequence) -> Text {
Text(s[s.index(s.startIndex, offsetBy: 1)..<s.index(s.endIndex, offsetBy: -1)])
}
private func msgDeliveryError(_ err: String) {
AlertManager.shared.showAlertMsg(
title: "Message delivery error",
message: err
)
}
}
struct TextItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
TextItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360)
TextItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello"), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), width: 360)
}
.previewLayout(.fixed(width: 360, height: 70))
}
}

View File

@@ -0,0 +1,45 @@
//
// ChatItemView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 30/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private var dateFormatter: DateFormatter?
struct ChatItemView: View {
var chatItem: ChatItem
var width: CGFloat
var body: some View {
if (isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else {
TextItemView(chatItem: chatItem, width: width)
}
}
}
func getDateFormatter() -> DateFormatter {
if let df = dateFormatter { return df }
let df = DateFormatter()
df.dateFormat = "HH:mm"
dateFormatter = df
return df
}
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), width: 360)
}
.previewLayout(.fixed(width: 360, height: 70))
}
}

View File

@@ -0,0 +1,142 @@
//
// ChatView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 27/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
@State private var inProgress: Bool = false
@FocusState private var keyboardVisible: Bool
@State private var showChatInfo = false
var body: some View {
let cInfo = chat.chatInfo
return VStack {
GeometryReader { g in
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 5) {
ForEach(chatModel.chatItems, id: \.id) {
ChatItemView(chatItem: $0, width: g.size.width)
.frame(minWidth: 0, maxWidth: .infinity, alignment: $0.chatDir.sent ? .trailing : .leading)
}
.onAppear {
DispatchQueue.main.async {
scrollToFirstUnread(proxy)
}
markAllRead()
}
.onChange(of: chatModel.chatItems.count) { _ in
scrollToBottom(proxy)
}
.onChange(of: keyboardVisible) { _ in
if keyboardVisible {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
scrollToBottom(proxy, animation: .easeInOut(duration: 1))
}
}
}
}
}
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
}
Spacer(minLength: 0)
SendMessageView(
sendMessage: sendMessage,
inProgress: inProgress,
keyboardVisible: $keyboardVisible
)
}
.navigationTitle(cInfo.chatViewName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { chatModel.chatId = nil } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.backward")
Text("Chats")
}
}
}
ToolbarItem(placement: .principal) {
Button {
showChatInfo = true
} label: {
ChatInfoToolbar(chat: chat)
}
.sheet(isPresented: $showChatInfo) {
ChatInfoView(chat: chat, showChatInfo: $showChatInfo)
}
}
}
.navigationBarBackButtonHidden(true)
}
func scrollToBottom(_ proxy: ScrollViewProxy, animation: Animation = .default) {
withAnimation(animation) { scrollToBottom_(proxy) }
}
func scrollToBottom_(_ proxy: ScrollViewProxy) {
if let id = chatModel.chatItems.last?.id {
proxy.scrollTo(id, anchor: .bottom)
}
}
// align first unread with the top or the last unread with bottom
func scrollToFirstUnread(_ proxy: ScrollViewProxy) {
if let cItem = chatModel.chatItems.first(where: { $0.isRcvNew() }) {
proxy.scrollTo(cItem.id)
} else {
scrollToBottom_(proxy)
}
}
func markAllRead() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if chatModel.chatId == chat.id {
markChatRead(chat)
}
}
}
func sendMessage(_ msg: String) {
do {
let chatItem = try apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg))
chatModel.addChatItem(chat.chatInfo, chatItem)
} catch {
logger.error("ChatView.sendMessage apiSendMessage error: \(error.localizedDescription)")
}
}
}
struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
chatModel.chatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
ChatItem.getSample(4, .directRcv, .now, "hello again"),
ChatItem.getSample(5, .directSnd, .now, "hi there!!!"),
ChatItem.getSample(6, .directSnd, .now, "how are you?"),
ChatItem.getSample(7, .directSnd, .now, "👍👍👍👍"),
ChatItem.getSample(8, .directSnd, .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.")
]
return ChatView(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
.environmentObject(chatModel)
}
}

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