Private
Public Access
1
0

358 Commits

Author SHA1 Message Date
778d4b6650 core: attachment store: limit concurrent downloads 2025-09-10 14:23:02 -07:00
e8256a9e57 core: attachment mime: prefer jpg instead of jfif 2025-09-10 14:06:54 -07:00
4e8b161d26 wip: attachment MIME 2025-09-10 13:48:27 -07:00
469fd8fa13 gtk: add Makefile for making rpm 2025-09-07 18:32:57 -07:00
f09f45a66f gtk: rpmspec update 2025-09-07 18:17:52 -07:00
481ac7357c gtk: attempt to fix rpmspec 2025-09-07 18:10:39 -07:00
27c6ac1c47 Remove API references from READMEs 2025-09-06 19:55:04 -07:00
acbcf2f992 AI generated READMEs 2025-09-06 19:52:37 -07:00
577e8491c9 Add 'osx/' from commit '46755a07ef2e7aa9852d74c30e2c12f9fe8f2278'
git-subtree-dir: osx
git-subtree-mainline: 034026e88a
git-subtree-split: 46755a07ef
2025-09-06 19:38:26 -07:00
034026e88a Add 'android/' from commit '5d26ea956906cd31a6cc37e79b0a4cac77b3118b'
git-subtree-dir: android
git-subtree-mainline: 7fe2701272
git-subtree-split: 5d26ea9569
2025-09-06 19:37:14 -07:00
7fe2701272 Add 'server/' from commit '800090542d91beae40bc81fc41b67ba61c47da77'
git-subtree-dir: server
git-subtree-mainline: 6a4054c15a
git-subtree-split: 800090542d
2025-09-06 19:36:27 -07:00
6a4054c15a Add 'mock/' from commit '2041d3ce6377da091eca17cf9d8ad176a3024616'
git-subtree-dir: mock
git-subtree-mainline: 8216d7c706
git-subtree-split: 2041d3ce63
2025-09-06 19:35:49 -07:00
8216d7c706 Add 'gtk/' from commit '7d0dfb455aa86245231b383a92e79b3c08a12d5e'
git-subtree-dir: gtk
git-subtree-mainline: c710c6e053
git-subtree-split: 7d0dfb455a
2025-09-06 19:34:30 -07:00
c710c6e053 Add 'core/' from commit 'b0dfc4146ca0da535a87f8509aec68817fb2ab14'
git-subtree-dir: core
git-subtree-mainline: a07f3dcd23
git-subtree-split: b0dfc4146c
2025-09-06 19:33:33 -07:00
a07f3dcd23 Seed commit 2025-09-06 19:33:27 -07:00
46755a07ef Implements attachment uploading 2025-09-03 22:38:49 -07:00
b0dfc4146c Add TLS support 2025-09-03 22:23:45 -07:00
b2f8abfbff Reduce animations in transcript 2025-09-03 17:08:54 -07:00
7675894ba7 daemon update 2025-09-03 17:08:35 -07:00
fc02d86a68 UI support for uploading image attachments 2025-08-30 21:52:30 -06:00
236070ccc9 Better attachment placeholders 2025-08-30 00:39:35 -06:00
0595fbc651 This ended up being pebkac 2025-08-29 23:19:14 -06:00
44fa638b1c snoozer: try another auth method 2025-08-29 23:09:57 -06:00
8fcc7609b9 snoozer: fix auth 2025-08-29 23:08:37 -06:00
54f7f3a4db adds utilities > snoozer 2025-08-29 22:44:09 -06:00
8257b8dbd6 Handle xpc connection interruptions 2025-08-29 22:30:37 -06:00
92d5b99853 kordophone: better handling of url decoding errors 2025-08-29 22:08:56 -06:00
7992c03fb6 App icon, group member annotations, variable spacing 2025-08-29 21:47:51 -06:00
5f37f82a33 Fix multi-window, turn off sandboxing 2025-08-29 19:59:11 -06:00
41c5776d98 Embed mach service 2025-08-29 19:45:27 -06:00
54df338ce0 Some minor changes 2025-08-29 18:49:00 -06:00
0128723765 xpc: Fixes file handle explosion - drop fd after its copied via xpc 2025-08-29 18:48:16 -06:00
5da92a90d4 Adds keychain support 2025-08-29 15:12:54 -06:00
eb4426e473 UpdateMonitor: dont leak convo in log 2025-08-29 15:12:23 -06:00
c1507e9ee1 Merge branch 'wip/macos-xpc'
* wip/macos-xpc: (23 commits)
  auth: try switching to platform agnostic auth store
  daemon: fix crash when misconfigured
  xpc: better file descriptor handling
  xpc: adds OpenAttachmentFd
  xpc: full attachment data
  sync policy: only ignore empty bodies if there are no attachments
  xpc: include attachment guids
  cargo fmt
  xpc: Some cleanup
  xpc: refactor -- separate rpc impl and xpc glue
  xpc: refactor, less chatty logging
  xpc: Use reply port when replying to RPC messages
  cargo fmt
  xpc: implement signals
  xpc: implement rest of methods in kpcli except signals.
  cargo fmt
  xpc: Better type unpacking
  xpc: implement GetConversations
  xpc: kpcli: clean up client interface
  xpc: generic interface for dispatching methods
  ...
2025-08-25 01:01:56 -07:00
c30330a444 auth: try switching to platform agnostic auth store 2025-08-25 00:56:03 -07:00
402b5a5f80 autosyncing, appearance tweaks 2025-08-25 00:37:48 -07:00
f0fd738935 Settings, no password yet 2025-08-25 00:13:55 -07:00
f82123a454 daemon: fix crash when misconfigured 2025-08-25 00:09:57 -07:00
f0029d02e1 Implements attachment previewing 2025-08-24 23:38:35 -07:00
cc59fe4996 xpc: better file descriptor handling 2025-08-24 23:38:14 -07:00
eaa5966e99 xpc: adds OpenAttachmentFd 2025-08-24 23:20:25 -07:00
f2353461b3 xpc: full attachment data 2025-08-24 23:01:11 -07:00
f277fcd341 sync policy: only ignore empty bodies if there are no attachments 2025-08-24 19:46:28 -07:00
ee32a0398f xpc: include attachment guids 2025-08-24 19:46:16 -07:00
126a4cc55f Get swifty 2025-08-24 18:54:50 -07:00
b38df68eb2 Implements signals 2025-08-24 18:41:42 -07:00
3ee94a3bea Adds getting/sending messages 2025-08-24 17:58:37 -07:00
b5a2f318b4 initial commit 2025-08-24 16:24:21 -07:00
f239d1de19 cargo fmt 2025-08-24 16:20:14 -07:00
28738a1e92 xpc: Some cleanup 2025-08-24 16:19:56 -07:00
00bbc3b330 xpc: refactor -- separate rpc impl and xpc glue 2025-08-24 15:49:55 -07:00
73508bea9e xpc: refactor, less chatty logging 2025-08-24 15:34:05 -07:00
a93a773071 xpc: Use reply port when replying to RPC messages 2025-08-24 15:28:33 -07:00
fc62f0533d Initial Commit 2025-08-24 11:16:20 -07:00
06b27c041a cargo fmt 2025-08-24 11:04:41 -07:00
da813806bb xpc: implement signals 2025-08-24 10:36:39 -07:00
16db2caacc xpc: implement rest of methods in kpcli except signals. 2025-08-23 20:13:33 -07:00
0b7b35b301 cargo fmt 2025-08-23 20:02:54 -07:00
6f90e1c749 xpc: Better type unpacking 2025-08-23 20:01:13 -07:00
b7fabd6c05 xpc: implement GetConversations 2025-08-23 19:48:49 -07:00
885c96172d xpc: kpcli: clean up client interface 2025-08-23 19:41:12 -07:00
8ff95f4bf9 xpc: generic interface for dispatching methods 2025-08-23 19:24:42 -07:00
e51fa3abeb kordophoned becomes a lib 2025-08-20 23:12:31 -07:00
e9bda39d8a xpc: hacky implementation of GetVersion 2025-08-10 21:48:44 -07:00
7d0dfb455a Dockerfile: build with fedora 40 for adwaita 2025-08-08 15:47:45 -07:00
201982170f Adds makefile and dockerfile for building rpms 2025-08-08 15:42:21 -07:00
54b76109c2 Fixes rpm build 2025-08-08 13:47:21 -07:00
8cdcb049cf rpm packaging, includes systemd service 2025-08-08 11:56:14 -07:00
James Magahern
911454aafb first pass at xpc impl 2025-08-01 12:26:17 -07:00
43b668e9a2 Fix linux build 2025-07-31 19:40:03 -07:00
James Magahern
c7d620c1b5 kpcli: finish separation of daemon interface 2025-07-31 19:30:54 -07:00
James Magahern
0e034898b2 kpcli fix stage 1 2025-07-31 19:19:29 -07:00
James Magahern
8115f94121 kordophoned sans kpcli building on macos 2025-07-31 19:16:44 -07:00
5fa6c86a17 adds readme 2025-07-15 19:00:11 -07:00
356a1b85b9 version bump 2025-07-15 18:59:03 -07:00
3e43bd1434 repository: auto start service if not already running 2025-07-15 18:58:13 -07:00
c878141e61 rpmspec: these may not be necessary. 2025-07-15 18:43:53 -07:00
44bc7c0cb4 v1.0.0 2025-07-15 18:42:28 -07:00
349a644b0e Desktop/icon files, rpm dist 2025-07-15 18:42:05 -07:00
742703cb8e Version: 1.0.0 2025-07-15 18:04:11 -07:00
3197814098 Implement hybrid versioning approach 2025-07-15 16:39:57 -07:00
21703b9f8e AttachmentStore: less chatty logging 2025-06-27 00:54:40 -07:00
6e14585a12 EDS: Found the issue where address book sometimes doesn't load -v
The wrong source was getting selected. Not sure if this one is always a
decoy, there might be others that we aren't supposed to use. Happy that
it's working now though.
2025-06-27 00:48:20 -07:00
b043ff6f08 eds: still not able to resolve sometimes, some AI generated attempts at solving 2025-06-26 20:44:24 -07:00
9e3e6dc66f ContactResolver: implement in-memory cache for positive results 2025-06-26 18:50:58 -07:00
bb74604a74 transcript: show sender annotation if interrupted by date annotation 2025-06-26 18:48:52 -07:00
e73cf321c0 Add normalization for eds resolver 2025-06-26 18:37:23 -07:00
5a399cc6ca weird: need to filter out bidi control characters from sender handles from server 2025-06-26 18:33:08 -07:00
f6bb1a9b57 Don't overwrite already resolved participants, better naming of keys 2025-06-26 18:23:15 -07:00
bb19db17cd Started working on contact resolution 2025-06-26 16:23:53 -07:00
9f84969ff5 Multi-window support! 2025-06-18 18:21:11 -07:00
3379198940 Add double click gesture on image bubbles to open 2025-06-18 18:07:59 -07:00
0dece34012 Adds link clicking support 2025-06-18 17:36:32 -07:00
4ebd310b7a fix bug with clearing locked selection bubble 2025-06-18 17:01:01 -07:00
ccfea2883c Enables selection of bubbles using an invisible text view 2025-06-18 16:50:14 -07:00
3b6666cfc2 Mark conversation as read on movement 2025-06-18 15:32:37 -07:00
3b30cb77c8 Implements mark as read 2025-06-18 15:02:04 -07:00
a70adbb7f1 Implements marking conversations as read when clicked on 2025-06-18 15:00:54 -07:00
4170f13092 fixes crash when trying to copy image 2025-06-18 01:49:33 -07:00
d33b50cfb5 transcript: add copy right click action 2025-06-18 01:36:29 -07:00
fa6c7c50b7 Refactor: serverimpl -> dbus::agent, clean up main.rs 2025-06-18 01:03:14 -07:00
f0b7cff226 Remove this check: attachments could have no body 2025-06-17 20:52:21 -07:00
e1c579d23b Text size: this really should just read the default 2025-06-17 00:57:22 -07:00
16102f9f94 Fix animation clamping issues 2025-06-17 00:53:37 -07:00
54ca001892 Adds incoming bubble animations 2025-06-17 00:47:03 -07:00
2041d3ce63 Fix websocket 2025-06-17 00:39:50 -07:00
c70ae00d5b transcriptview perf: only draw the items that are actually visible. 2025-06-16 20:09:56 -07:00
032573d23b cargo fmt 2025-06-16 19:26:13 -07:00
75fe4d4608 fix all warnings 2025-06-16 19:25:24 -07:00
800090542d updates: should really return 401 for bad auth instead of 404 2025-06-16 19:18:14 -07:00
9d591dffc5 Try to resolve daemon hang when changing settings 2025-06-16 19:06:35 -07:00
45aaf55804 dbus: filter attachment characters here. not ideal... 2025-06-16 18:52:58 -07:00
2db0e3136e Some metrics tweaks for my laptop 2025-06-14 00:14:58 -07:00
31eeb8659a fix update reconnect notification when waking from sleep 2025-06-13 19:01:00 -07:00
4d466f0d26 re-fix the issue of accumulating message list models 2025-06-13 17:48:50 -07:00
b2049fb432 Workaround for empty server messages (typing indicator) 2025-06-13 17:47:29 -07:00
bb04bc4352 Remove this install step now that we static link 2025-06-13 17:42:55 -07:00
aace2a8dfc messagelist: actually implement before/after properly 2025-06-13 17:42:05 -07:00
9c013c3702 Strip space before sending messages 2025-06-13 17:14:23 -07:00
741932c67d Implement resync after update monitor reconnect 2025-06-13 17:13:04 -07:00
45d873907f bugfixes, better handling of server url changes 2025-06-13 17:11:29 -07:00
dece6f1abc daemon: update monitor: implements ping/pong (required server changes) 2025-06-13 16:45:28 -07:00
1420d96a20 TranscriptView: ellipsize title 2025-06-13 12:26:57 -07:00
4f40be205d Adds CONTENT_LENGTH workaround for CocoaHTTPServer bug 2025-06-12 21:19:47 -07:00
269271835f bug fixes 2025-06-12 20:47:36 -07:00
ff03e73758 plumb attachment guids for sendmessage 2025-06-12 20:36:40 -07:00
2d43b87839 add CLAUDE.md 2025-06-12 20:36:18 -07:00
6fb88c3a0d Switch from Entry to TextView for multiline, paste support for attachments 2025-06-12 20:35:56 -07:00
137da5b3d1 Finish daemon support for uploaded attachments + sending 2025-06-12 19:46:53 -07:00
f3e59b9951 Adds ui support for attachments, results not yet connected to daemon 2025-06-12 19:26:49 -07:00
8dbe36fde1 Repository: add support for attachment uploading 2025-06-12 18:13:59 -07:00
930f905efc Perf optimizations, recommended by o3 2025-06-12 18:09:58 -07:00
2f4e9b7c07 Implements attachment uploading 2025-06-12 17:58:03 -07:00
501bd3f604 Add back message list watching, support attachments without metadata 2025-06-12 17:54:09 -07:00
54790d1d70 Implements attachments display in transcript 2025-06-06 20:03:02 -07:00
4ddc0dca39 Notify when attachment download succeeds, fix deadlock in attachment store 2025-06-06 20:02:09 -07:00
1d3b2f25ba cargo fmt 2025-06-06 16:39:31 -07:00
8cd72d9417 cargo fix 2025-06-06 16:35:51 -07:00
9e8c976a0e remove some unused builder code in daemon::models::message 2025-06-06 16:30:22 -07:00
77e1078d6a plumb all known attachments via dbus if known 2025-06-06 16:28:29 -07:00
1a2dad08a5 adds image bubble layout for attachments 2025-06-06 14:33:40 -07:00
2e55f3ac9e dbus: remove some signals I wont implement 2025-06-05 20:21:30 -07:00
cbc7679f58 AttachmentStore now has its own runloop, can download attachments 2025-06-05 20:19:34 -07:00
595c7a764b adds CLAUDE hints 2025-05-28 14:57:12 -07:00
e55b29eb4d plub through attachment guids via messages 2025-05-26 16:52:38 -07:00
2b5df53cc3 better d-bus interface for attachments 2025-05-26 16:19:26 -07:00
831e490eb4 Started to factor out DbusRegistry from Endpoint 2025-05-26 15:49:29 -07:00
c02d4ecdf3 broken: started working on attachment dbus object, but order of endpoint creation seems to matter, need to reuse more parts 2025-05-25 18:52:18 -07:00
0d4c2e5104 Started working on attachment store 2025-05-15 20:11:10 -07:00
77177e07aa kpcli: fix for update data structure changes 2025-05-14 17:43:28 -07:00
83eb97fd9c websocket: automatically reconnect if not heard from for a while 2025-05-14 17:39:23 -07:00
1ed7f5bda3 Fix retain cycles 2025-05-14 17:37:23 -07:00
4ad9613827 temporary solution for infinite sync: just remember the times 2025-05-12 20:46:26 -07:00
f377bbb7f9 Change unread indicator from number to icon 2025-05-04 00:49:21 -07:00
4aa6e53e3a adjust date attribution time a bit 2025-05-04 00:17:50 -07:00
819b852c1f Fixes bug where updates can cause a sync loop 2025-05-04 00:15:13 -07:00
f38e2a9798 Nicer app menu 2025-05-04 00:14:00 -07:00
d4cc3358b7 reorg: message-list-view -> transcript-view 2025-05-04 00:13:47 -07:00
3e9e8fb3d0 transcriptview: reset scroll position when model changes 2025-05-03 23:39:21 -07:00
7ccdbced30 Show conversation display name in title 2025-05-03 23:26:53 -07:00
786d982ce0 Add sender annotations 2025-05-03 23:19:15 -07:00
dd91746310 reorg: message-list -> transcript 2025-05-03 22:47:56 -07:00
d3dfffd652 show dates in transcript 2025-05-03 22:41:51 -07:00
8e87c2bce2 Less chattier log when syncing 2025-05-03 22:13:03 -07:00
21c926456d reorg: message layout becomes interface for other types of chat items (like date) 2025-05-03 22:12:26 -07:00
d843127c6d daemon: maintain outgoing message reference so model is consistent 2025-05-03 21:45:53 -07:00
518608a04e attempt to resolve chatter problems 2025-05-03 21:45:17 -07:00
0d61b6f2d7 daemon: adds conversation list limit, fixes auth saving in db auth store 2025-05-03 18:19:48 -07:00
e44120712f fixes for very large conversation lists 2025-05-03 18:19:17 -07:00
0f565756df adds setting screen 2025-05-03 01:11:26 -07:00
26d54f91d5 implements authentication/token retrieval/keyring 2025-05-03 01:06:50 -07:00
ecf66131e9 server: enqueue message update after sending. real server does this 2025-05-02 15:52:01 -07:00
ef0312ccbd ~buzzert/Kordophone#9: gtk v2: Conversation selected state lost when reloading 2025-05-02 15:51:43 -07:00
461c37bd20 daemon: updatemonitor: dont sync convo list on conversation update, only message sync 2025-05-02 15:46:33 -07:00
410182eab8 implements sending 2025-05-02 15:09:12 -07:00
2519bc05ad daemon: implements post office 2025-05-02 14:22:43 -07:00
07b55f8615 client: implements send_message 2025-05-02 12:03:56 -07:00
05b4beb2fb fix bug where mock server crashes when sending more than one update 2025-05-01 20:48:43 -07:00
2106bce755 daemon: reorg 2025-05-01 20:45:20 -07:00
2314713bb4 daemon: incorporate update monitor in daemon activities 2025-05-01 20:36:43 -07:00
1c2f09e81b clippy 2025-05-01 18:08:04 -07:00
f6ac3b5a58 client: implements event/updates websocket 2025-05-01 18:07:18 -07:00
13a78ccd47 adds the ability to clear db 2025-05-01 01:08:24 -07:00
fd4c43d585 client: actually do authentication properly 2025-05-01 01:02:36 -07:00
f80d1a609b attempt to resolve scaling issues on 2x displays 2025-04-30 21:19:24 -07:00
a7e88bd3c3 wire up message loading 2025-04-30 19:50:36 -07:00
bdf76ca725 generators: sometimes generate messages from me as well 2025-04-30 19:46:09 -07:00
4c7c31ab8d implement bubble view 2025-04-30 19:12:00 -07:00
e976b3db4c initial scaffolding for inverted, custom message list 2025-04-30 15:58:47 -07:00
3e1fa63fdf reorg: separate dbus code out of conversation list model and into repository 2025-04-30 15:19:44 -07:00
56fba9b72c Use generated dbus interface rather than editing it every time 2025-04-30 14:53:17 -07:00
59cfc8008b dbus: remove duplicate property for credential item 2025-04-30 14:51:49 -07:00
907a69385d reorg 2025-04-30 14:24:33 -07:00
101694ddbc Some fixups for the badge 2025-04-28 18:40:16 -07:00
7200ae54e4 Adds the ability to sync just one conversation 2025-04-28 18:39:52 -07:00
a1250c8ebe adds dbus messaging for getting conversations. needs org 2025-04-28 18:21:02 -07:00
4eff88a51b initial commit: barebones 2025-04-28 17:29:32 -07:00
e7d837d68c cargo clippy/fix 2025-04-28 16:06:51 -07:00
c189e5f9e3 daemon: add support for getting messages from db 2025-04-28 16:00:04 -07:00
9c245a5b52 client: Started working on ability to sync messages after last known message 2025-04-28 15:17:58 -07:00
6375284d9e daemon: copy audit, cleanup 2025-04-27 23:27:21 -07:00
1e9b570993 devises a strategy for signals 2025-04-27 22:44:05 -07:00
cecfd7cd76 implements settings, conversation dbus encoding 2025-04-27 18:07:58 -07:00
49f8b81b9c daemon: Token store 2025-04-27 14:01:19 -07:00
84f782cc03 daemon: implement solution for background sync 2025-04-27 13:40:59 -07:00
22554a7644 daemon: reorg: use channels for comms instead of copying daemon arc/mutex 2025-04-27 12:53:45 -07:00
ef74df9f28 daemon: start working on events. notes:
Probably need to make the locking mechanism more granular. Only lock the
database during db writes, see if we can do multiple readers and a
single writer. Otherwise, the daemon will not be able to service
requests while an event is being handled, which is not good.
2025-04-25 21:43:36 -07:00
82192ffbe5 daemon: setting foundation for client creation 2025-04-25 20:02:18 -07:00
fe32efef2c daemon: scaffolding for settings / sync 2025-04-25 18:02:54 -07:00
0c6b55fa38 kordophoned: better daemon bootstrapping 2025-04-25 16:54:37 -07:00
b1f171136a refactor: with_repository/with_settings 2025-04-25 16:35:10 -07:00
89c9ffc187 cleanup 2025-04-25 15:48:50 -07:00
f7d094fcd6 reorg: split repo / database so settings can use db connection as well 2025-04-25 15:42:46 -07:00
dd9025cc10 daemon: main reorg 2025-02-12 00:32:44 -08:00
68ff158d6c kordophoned: reorg: server impl in separate file, skeleton for conversations 2025-02-12 00:26:32 -08:00
6a7d376aa9 kpcli: add daemon messaging support 2025-02-12 00:10:33 -08:00
fddc45c62a Adds kordophoned, basic dbus interface 2025-02-11 23:15:24 -08:00
16c202734c kpcli: db: add support for printing messages table 2025-01-20 22:23:18 -08:00
bfc6fdddc1 proj: Fix warnings 2025-01-20 22:13:44 -08:00
5d3d2f194a kpcli: adds support for querying messages 2025-01-20 22:05:53 -08:00
146fac2759 kordophone-db: adds support for the Messages table 2025-01-20 22:05:34 -08:00
a8104c379c kordophone: add support for /messages 2025-01-20 19:43:21 -08:00
793faab721 kpcli: adds 'db' subcommand for interacting with the database 2025-01-08 13:32:55 -08:00
5d26ea9569 Background UpdateMonitorService: specify intent to Android OS 2024-12-30 19:21:23 -08:00
89f8d21ebb clippy/cleanup 2024-12-21 18:01:00 -08:00
53d4604b63 remove unused Date model 2024-12-21 17:52:11 -08:00
ab44a169e6 reorg: move tests to separate module 2024-12-21 17:50:47 -08:00
8f523fd7fc reorg: separate db::records from insertable models 2024-12-21 17:09:37 -08:00
c4c6e4245d chatdatabase: implements all_conversations with participant zippering 2024-12-21 16:34:47 -08:00
f79cbbbc85 kordophone-db: switch to diesel for more features 2024-12-14 19:03:27 -08:00
86601b027a kordophone-db: participants, but still need to "upsert" these
Might move to SeaORM instead of trying to do this with microrm
2024-12-14 12:53:44 -08:00
fac9b1ffe6 adds kordophone-db 2024-12-08 21:12:17 -08:00
75d4767009 kpcli: reorg subcommands 2024-12-08 15:08:15 -08:00
1eb08ba464 Makefile for muscle memory 2024-12-08 15:06:35 -08:00
0e8b8f339a kpcli: client: adds printing of conversations 2024-11-10 19:40:39 -08:00
6b9f528cbf start working on kpcli 2024-11-09 17:36:25 -08:00
a11dd27ef8 Vanity domain update: drop v2 2024-09-06 01:12:11 -07:00
c1fef50c0c Update module with vanity domain 2024-09-06 01:10:45 -07:00
da36d9da91 Clippy, warnings fix 2024-06-14 20:26:56 -07:00
cabd3b502a Retry auth automatically, remove tower dep 2024-06-14 20:23:44 -07:00
0dde0b9c53 clippy 2024-06-01 18:17:57 -07:00
a2caa2ddca prepare for tower middleware adoption 2024-06-01 18:16:25 -07:00
cf4195858e Started work on http server 2024-04-24 23:41:42 -07:00
48dcf9daca Fix tests 2024-04-21 23:09:37 -07:00
3e878ced4e reorg 2024-04-21 15:14:16 -07:00
0b2811dc9f Initial commit 2024-04-20 18:17:55 -07:00
634540a703 ~buzzert/Kordophone#8: android: Show spinner in full screen attachment viewer 2024-04-15 22:21:24 -07:00
d2afecafcf Try only synchronizing messages after last GUID 2024-04-15 19:38:40 -07:00
50e9971694 Adds app icon 2024-04-15 18:41:39 -07:00
6ed8e88bf0 More pleasant OOTB experience 2024-04-10 23:35:54 -07:00
d474ce1c10 Attachments: implements proper caching 2024-04-09 00:11:53 -07:00
20e33f70a8 attachments: proper metadata plumbing 2024-04-08 12:07:34 -07:00
5a148e2b20 Adds support for uploading attachments 2024-04-07 23:03:33 -07:00
b47132fd05 attachments: handle sending messages with attachments 2024-04-07 21:50:08 -07:00
95b358e66e attachments: Should be /attachment, not /fetchAttachment 2024-04-07 21:25:59 -07:00
35c720106e MessageListScreen: prettier image loading errors 2024-04-07 21:23:40 -07:00
a1349eff1b attachments: Add a generated attachment conversation 2024-04-07 21:06:03 -07:00
fa76c7eac1 Adds attachment fetching/uploading 2024-04-07 20:22:38 -07:00
b5eccbd000 app: MessageEntry: Adds UI support for uploading attachments
Still need backend support to finish this.
2024-04-04 23:52:17 -07:00
63876104aa server: enforce auth for updates 2024-04-04 22:59:59 -07:00
b160baae3e backend: don't fetch messages with conversations. 2024-03-29 01:20:08 -07:00
46c2fd6bf9 Notifications: deep linking 2024-03-29 01:04:09 -07:00
c2786d268f Implements notifications 2024-03-28 23:40:18 -07:00
8d63c5f1f5 build: Adds x86/64, split into separate APKs 2024-03-28 00:50:50 -07:00
e3b4b77307 .builds -> .build.yml 2024-03-25 19:31:18 -07:00
8008406730 MessageEntryView: appearance tweaks 2024-03-24 23:45:19 -07:00
c040e4e2d1 builds: swap gradle.properties secret 2024-03-24 23:03:53 -07:00
7cfd4e28f5 builds: use signed artifact 2024-03-24 22:51:46 -07:00
7a1ec47f86 builds: Adds signing secrets 2024-03-24 22:49:05 -07:00
74a54f009b Fix builds 2024-03-24 00:36:50 -07:00
c68e32c472 builds: switch to ubuntu 2024-03-24 00:31:23 -07:00
ee20be4240 Add builds spec 2024-03-23 23:56:14 -07:00
aa8acb3fe0 build: Move Previews to separate debug-only module 2024-03-23 23:08:44 -07:00
f8ee8458ef AttachmentViewer: Disable click indication 2024-03-23 22:50:49 -07:00
3d165b6acd Links in message bubbles 2024-03-23 20:22:05 -07:00
f266e04895 Much better error/badconfig handling 2024-03-23 19:01:20 -07:00
611ad15997 AttachmentViewer: Make it zoomable using a library 2024-03-23 18:27:15 -07:00
9f37b57876 Adds attachment viewer when clicking on an attachment 2024-03-23 18:09:13 -07:00
9f5f2d7af5 Adds image attachment support 2024-03-23 17:04:14 -07:00
b23ab2dfe2 ChatRepository: Removing from message queue should block 2024-03-19 01:34:33 -07:00
66c2752096 ChatRepository: Bump up sync limit a bit 2024-03-19 00:55:43 -07:00
09294e7da9 MessageListScreen: Annotate sender in group chats 2024-03-19 00:48:15 -07:00
3d3abc1813 MessageListScreen: Add time annotations/max width 2024-03-19 00:15:36 -07:00
1c78ecc93d UpdateMonitor: Fix authentication with update monitor 2024-03-18 23:15:17 -07:00
c56776842a ConversationListScreen: Implements pull-to-refresh 2024-03-18 22:48:02 -07:00
3f670671e9 NoContentView: Configurable text and icon 2024-03-18 22:20:44 -07:00
0cf9baba7c Adds NoContentView (not yet used) 2024-03-06 22:26:41 -08:00
a191ab044d ConversationListScreen: Fix clipping for list cell 2024-03-02 15:37:03 -08:00
d0be011053 Backend: UI: Implements authentication. 2024-03-02 00:38:17 -08:00
c666083e4b Fix tests: Need to write proper date unmarshalling also 2024-03-01 23:01:57 -08:00
222ec84855 BackendTests: Fix Backend Tests. 2024-03-01 21:46:14 -08:00
077b12b1ac Settings: Save server, username, password 2024-02-26 23:03:10 -08:00
175a83ca21 Adds ServerConfigRepository that view models listen to
* app/src/main/java/net/buzzert/kordophonedroid/ui/shared/ServerConfigRepository.kt:
2024-02-26 01:10:55 -08:00
94bb9baa72 SettingsScreen: username/password 2024-02-26 00:29:53 -08:00
ed4e3cd0bb SettingsScreen: Started working on settings screen
Still need to do auth
2024-02-25 19:42:10 -08:00
James Magahern
831636216d Ensure all dates returned are ISO8601 2024-01-05 16:26:19 -08:00
a8043e53b3 Update SendMessage for new protocol 2024-01-03 23:46:10 -08:00
f242a08967 gitignore: release 2023-12-11 00:28:33 -08:00
78efe78d4b Implement auto mark as read for conversations 2023-12-10 23:59:34 -08:00
d8ca07f92a Actually check authentication 2023-12-10 19:51:18 -08:00
5a672c4b2e ResponseDecodeError should report underlying error 2023-12-10 19:50:28 -08:00
7db8c39042 Better error handling 2023-12-10 19:37:53 -08:00
a8886279c6 Update send message to account for guid returned by servers 2023-12-10 18:30:56 -08:00
416949095c Update sendMessage to return inserted guid for new protocol 2023-12-10 17:45:40 -08:00
f222078a9d Identify ourselves as KordophoneMock 2023-12-02 22:23:41 -08:00
6375900710 Implements message loading and display 2023-08-24 00:45:18 -07:00
a0e8e049f0 Updates: need to have one channel per observer 2023-08-24 00:30:57 -07:00
7895a3a2e0 Fix bug where generated messages have wrong guids 2023-08-23 21:29:13 -07:00
4add2b4674 Better database syncing... maybe 2023-08-19 01:22:56 -07:00
19a6960ab2 Implement deletion of non-existent conversations 2023-08-17 01:53:37 -07:00
84e3a27c62 Fix list updates (it was isEqual) 2023-08-17 01:44:19 -07:00
770bb496f5 Trying to plumb list changes... 2023-08-17 01:07:15 -07:00
b38c51d108 Fix for update 2023-08-17 00:42:32 -07:00
4eef98aef4 Start integration with app: adds Hilt for dep injection, implements a view model 2023-08-17 00:37:50 -07:00
28f2bfe580 Implements message watching per conversation 2023-08-14 00:12:38 -07:00
f6affec830 Adds update/db monitoring of messages added 2023-08-13 19:59:04 -07:00
62ea95099a Implements UpdateMonitor 2023-08-13 18:41:49 -07:00
b92d1a2892 server: implements /updates websocket 2023-08-13 00:33:46 -07:00
1feb7ea6f6 Started working on synchronization 2023-08-10 01:10:15 -07:00
e9a9d1c064 prompt: adds 'version' command 2023-08-10 00:42:49 -07:00
56ad3a49ea Kordophone-2.6: lastMessage in /conversations 2023-08-10 00:42:21 -07:00
f43c348f92 BackendTest: add message/convo generation features 2023-08-09 23:47:00 -07:00
32e68a80b2 Integrate ChatRepository and CachedChatDatabase 2023-08-09 23:00:28 -07:00
4fda6428ce Adds Message retrieval 2023-08-09 02:00:52 -07:00
4724ae5728 Started working on Cache Database 2023-08-09 01:32:38 -07:00
f13a809f1e PerformMessageQuery: Add comment regarding sort assumption 2023-08-08 21:39:43 -07:00
6a0b7ca225 Implement beforeMessageGUID/afterMessageGUID/beforeDate 2023-08-08 21:38:04 -07:00
5c9f580dff Cool ASCII art welcome message 2023-08-08 21:37:51 -07:00
fde5bb9f53 MessageEntry: UI tweaks 2023-08-07 23:51:30 -07:00
67af3ada35 Fix previews 2023-08-07 23:37:40 -07:00
98d0a90544 SendMessage: plumb a guid through to uniquely identify a pending message 2023-08-07 23:00:08 -07:00
0f87ea2200 Implements /sendMessage using a message queue 2023-08-07 22:52:33 -07:00
7a5e1fd338 Implements /message fetching 2023-08-07 21:30:46 -07:00
203e1c5ed9 Upgrade project and dependencies 2023-08-07 19:35:42 -07:00
2e9b62b654 Adds backend module, tests for /version and /conversations (not real tests yet) 2023-08-06 12:27:58 -07:00
b77020e23c server: implements /markConversation 2023-07-19 12:10:25 -06:00
1fce2c7cb3 Implements pollUpdates 2023-07-19 11:58:13 -06:00
7611bedef7 conversation: adds Equal() 2023-07-19 10:56:49 -06:00
16148949c8 Some progress on message list UI 2023-07-07 00:11:13 -07:00
ac27ac2d14 prompt: Implements receive message 2023-06-23 00:56:06 -07:00
943f52ac45 server: Implements sendMessage 2023-06-23 00:44:25 -07:00
06046ac266 prompt: adds ls, help, mark 2023-06-23 00:32:17 -07:00
191cffd4cf web/server: stub /pollUpdates 2023-06-22 23:55:46 -07:00
732e9b9667 generator: Generate names deterministically 2023-06-22 23:45:43 -07:00
James Magahern
a4c25df183 Prompt: adds interactive prompt (that does nothing right now) 2023-06-22 12:03:37 -07:00
James Magahern
27de41ddb2 Retab 2023-06-22 11:06:18 -07:00
James Magahern
84dbb7f006 Switch to zerolog 2023-06-22 11:03:00 -07:00
3613aac4c1 Log API endpoints 2023-06-19 20:34:06 -07:00
64c5169542 server: messages: add TODO regarding optional params 2023-06-19 18:32:36 -07:00
2d415a1170 messages: Implements /messages API 2023-06-19 18:31:05 -07:00
cdf3d922f7 Better generated names and messages 2023-06-19 12:57:21 -07:00
63ac783e18 conversation: DisplayName 2023-06-18 13:12:06 -07:00
6bbcf8cc63 Authentication: Implements authentication 2023-06-18 13:11:51 -07:00
53870e25a9 server: Return proper version 2023-06-16 23:38:48 -07:00
a2b14c88ea Initial commit: conversaions, status, version 2023-06-16 23:35:41 -07:00
27e3c09228 ConversationListScreen: Unread indicator, time modified 2023-06-12 01:11:57 -07:00
e547cec441 Initial commit -
Basic list of conversations (UI only) are working using Jetpack Compose.

Next step is to try and get data loading from a Room database.

Ideally, I can have a test data store and a real data store, and some way to switch between them.
2023-06-11 21:04:14 -07:00
347 changed files with 27099 additions and 48 deletions

105
README.md
View File

@@ -1,56 +1,79 @@
# Entitlements # Kordophone Monorepo
You might to enable this default to use private entitlements Kordophone is an iMessage bridge: a lightweight server runs on a Mac and exposes an HTTP API so nonApple devices can send/receive iMessages. A set of clients (Android, Linux GTK, macOS Cocoa) talk to that API. A shared Rust library powers most clients, and a mock server helps local testing.
```
sudo defaults write /Library/Preferences/com.apple.security.coderequirements Entitlements -string always > Important: Interfacing with iMessage on macOS involves private APIs and restricted entitlements. See `server/README.md` for details and safety notes.
## Repository Layout
Toplevel projects in this monorepo:
- `server/` — macOS daemon that bridges to iMessage and exposes an HTTP REST API. Written in ObjectiveC/Cocoa. See `server/README.md`.
- `core/` — Rust workspace with the shared Kordophone client library and related tooling. Used by `gtk/` and `osx/`.
- `gtk/` — GTK4/Libadwaita Linux desktop client built on the Rust `core` library.
- `osx/` — macOS Cocoa client that uses the Rust `core` library and talks to the local Kordophone client daemon via XPC.
- `android/` — Android client. Currently implements its own API client (does not use the Rust library yet).
- `mock/` — Gobased mock server that emulates a Mac running the Kordophone server, for local development and tests.
Quick links:
- Server (mac daemon): `server/`
- Core (Rust workspace): `core/`
- GTK client (Linux): `gtk/`
- macOS client (Cocoa): `osx/`
- Android client: `android/`
- Mock server (Go): `mock/`
## How It Works
1. The macOS Kordophone Server (`server/`) runs on a Mac with iMessage and exposes an HTTP/JSON API and a WebSocket/updates channel.
2. Clients connect to that server to list conversations, fetch/send messages, upload/download attachments, and receive live updates.
3. A Rust library in `core/kordophone` implements the wire protocol and client behaviors. Linux/macOS clients use this library directly; Android currently ships a native Kotlin client.
4. The Rust client daemon (`core/kordophoned`) provides local caching and IPC (DBus on Linux, XPC on macOS) for GUI apps.
5. The `mock/` server simulates the server API for local development without a Mac.
## Getting Started
You can try the clients quickly against the mock server.
1) Run the mock server (no auth):
```bash
cd mock
go run ./... # or: make; ./kordophone-mock
``` ```
Maybe a better thing to do is to DYLD_PRELOAD `imagent` and swizzle `IMDAuditTokenTaskHasEntitlement` to always return YES. The mock server listens on `http://localhost:5738`.
Included in the project is "kordophoned-RestrictedEntitlements.plist", which contains all necessary restricted entitlements. 2) Point a client at the mock server:
On production macOS builds, the kernel will kill kordophoned immediately if it's signed using restricted entitlements, so agent hook is a
better option when running on prod machines. By default, the project is configured to ignore kordophoned-RestrictedEntitlements.plist when building.
## Building/linking - Android: open Settings in the app and set the server host to `http://10.0.2.2:5738` (Android emulator) or `http://<your-host-ip>:5738` if running on device. Disable auth unless you started the mock with `--auth`.
If you get dyld errors running from the command line, use `install_name_tool` to update the @rpath (where @rpath points to where linked Frameworks like GCDWebServer is). - GTK (Linux): build and run the GTK app from `gtk/` (see its README) and configure it to use the local daemon backed by the server URL.
`install_name_tool -add_rpath . ./kordophoned` - macOS (Cocoa): build the app in `osx/` (see `osx/README.md`).
To use a real Mac server instead, set your clients server URL to the host running `server/` with the correct scheme/port and authentication.
## Running ## Building
You need to hook imagent first to bypass entitlements check. Look at `hookAgent.sh`
Below are brief notes. Each subprojects README has more detail.
## SSL - Server (macOS): open `server/MessagesBridge.xcodeproj` in Xcode. See `server/README.md` for entitlements, SSL, and running.
If you want to run with SSL, you have to generate a self-signed certificate, and have the Mac trust the root cert. - Core (Rust): install Rust (stable) and run `cargo build` inside `core/`. See `core/README.md`.
- GTK (Linux): see `gtk/README.md` for RPM build via `rpmbuild -ba dist/rpm/kordophone.spec`.
- macOS (Cocoa): open `osx/kordophone2.xcodeproj` in Xcode. See `osx/README.md`.
- Android: open `android/` in Android Studio and build. See `android/README.md` for configuration.
- Mock server (Go): `cd mock && go run ./...` or `make`.
### Generate a root cert ## Security and Entitlements
1. Generate root key
`openssl genrsa -out Kordophone-root.key 4096`
2. Generate root certificate
`openssl req -x509 -new -nodes -key Kordophone-root.key -sha256 -days 1024 -out Kordophone-root.crt`
3. Add this certificate to the Mac's trust store via Keychain Access. Set to "Always Trust"
### Create signing certificate by signing a new cert with the root cert The macOS server uses private APIs and restricted entitlements. On production macOS builds, processes with restricted entitlements can be killed by the kernel; development requires workarounds (e.g., swizzling, hooking `imagent`) and careful code signing. See `server/README.md` for instructions and caveats.
1. Generate signing key
`openssl genrsa -out kp.localhost.key 2048`
2. Create certificate signing request
`openssl req -new -key kp.localhost.key -out kp.localhost.csr`
3. Sign the cert with the root cert
`openssl x509 -req -in kp.localhost.csr -CA Kordophone-root.crt -CAkey Kordophone-root.key -CAcreateserial -out kp.localhost.crt -days 365 -sha256`
4. kordophoned works with a signing cert in PKCS12 format. Convert the cert and the privkey to PKCS12
`openssl pkcs12 -export -in kp.localhost.crt -inkey kp.localhost.key -out certificate.p12 -name "Kordophone"`
### Start kordophone with the SSL options and provide the p12 ## Status
`kordophoned -s -c certificate.p12`
## Authentication - Android client: ships its own API client (not yet using Rust `core`).
Basic Authentication is also optional, but requires SSL to be enabled as well. To configure basic authentication, create a file containing your username and password on two separate lines encrypted with your GPG key. - GTK + macOS clients: use the Rust `core` library and integrate with the `kordophoned` client daemon for caching/IPC.
- Mock server: useful for development; implements common endpoints and WebSocket updates.
`echo "username\npassword" > password.txt"` ## Contributing
`gpg -e -r (your email) -o password.asc password.txt`
Then run kordophoned with the following option
`kordophone -s -c certificate.p12 -a password.asc`
You may need to unlock your GPG keyring (via gpg-agent) when running kordophoned the first time.
Issues and PRs are welcome. If you add a new client or endpoint, please update relevant READMEs and link it from this root README. Prefer small, focused changes and keep style consistent with the existing code.

32
android/.build.yml Normal file
View File

@@ -0,0 +1,32 @@
image: ubuntu/jammy
packages:
- openjdk-18-jdk
- gradle
- maven
sources:
- https://git.sr.ht/~buzzert/KordophoneDroid
secrets:
- a24d65d9-3e71-40e9-946d-0e9b73efacee # ~/.gradle/gradle.properties: contains keystore passwords
- 4fbe9d83-5f38-49c0-b93d-863d15e92a60 # ~/keystore.jks: Android keystore
tasks:
- setup: |
wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip commandlinetools-linux-11076708_latest.zip
mkdir android-sdk
yes | ./cmdline-tools/bin/sdkmanager --sdk_root=android-sdk --licenses
./cmdline-tools/bin/sdkmanager --sdk_root=android-sdk "build-tools;34.0.0" "platforms;android-33"
- build: |
export ANDROID_HOME=~/android-sdk
cd KordophoneDroid/
./gradlew assembleRelease
- prepare: |
cd KordophoneDroid/app/build/outputs/apk/release/
cp app-arm64-v8a-release.apk ~/kordophone-arm64-v8a-release.apk
cp app-armeabi-v7a-release.apk ~/kordophone-armeabi-v7a-release.apk
cp app-x86_64-release.apk ~/kordophone-x86_64-release.apk
cp app-x86-release.apk ~/kordophone-x86-release.apk
artifacts:
- kordophone-arm64-v8a-release.apk
- kordophone-armeabi-v7a-release.apk
- kordophone-x86_64-release.apk
- kordophone-x86-release.apk

17
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
.idea/

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

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

6
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="1.8" />
</component>
</project>

20
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="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/backend" />
</set>
</option>
<option name="resolveExternalAnnotations" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>

View File

@@ -0,0 +1,41 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

6
android/.idea/kotlinc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.8.22" />
</component>
</project>

9
android/.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

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

46
android/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Kordophone Android Client
Android client for the Kordophone iMessage bridge. This app connects to a running Kordophone server over HTTP/JSON and streams updates.
Note: This client currently implements its own API layer in Kotlin and does not yet use the shared Rust `core` library.
## Build & Run
Requirements:
- Android Studio (AGP 8.x)
- JDK 8+ toolchain (project uses Java 8 bytecode)
Steps:
1. Open `android/` in Android Studio.
2. Sync Gradle and build the project.
3. Run the `app` configuration on an emulator or device.
The app targets `minSdk 30`, `targetSdk 33` and uses Jetpack Compose.
## Configure Server
Set the server host and optional Basic Auth from the apps Settings screen.
- Server URL for local mock (emulator): `http://10.0.2.2:5738`
- Server URL for local mock (device): `http://<your-host-ip>:5738`
- Credentials: match the server if auth is enabled (mock supports `--auth`).
Settings are persisted using `EncryptedSharedPreferences`.
## Modules
- `app/` — UI (Compose), DI (Hilt), app wiring
- `backend/` — Kotlin API client, DB/cache (Realm), models, tests
## Testing with the Mock Server
Start the mock server in the repo root:
```bash
cd mock
go run ./... # or: make; ./kordophone-mock
```
Set the apps server URL to the mock address and run. The mock implements the Kordophone server API; see centralized API documentation (planned under `api/`).

2
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build
/release

155
android/app/build.gradle Normal file
View File

@@ -0,0 +1,155 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'com.google.dagger.hilt.android'
}
android {
namespace 'net.buzzert.kordophonedroid'
compileSdk 33
defaultConfig {
applicationId "net.buzzert.kordophonedroid"
minSdk 30
targetSdk 33
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
signingConfigs {
if (project.hasProperty('RELEASE_STORE_FILE')) {
release {
storeFile file(RELEASE_STORE_FILE)
storePassword RELEASE_STORE_PASSWORD
keyAlias RELEASE_KEY_ALIAS
keyPassword RELEASE_KEY_PASSWORD
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (project.hasProperty('RELEASE_STORE_FILE')) {
signingConfig signingConfigs.release
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
}
buildFeatures {
compose true
}
composeOptions {
// Note: this is strictly tied to a kotlin version, but isn't the version of kotlin exactly.
// See: https://developer.android.com/jetpack/androidx/releases/compose-kotlin
kotlinCompilerExtensionVersion '1.4.8'
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
splits {
abi {
// Enable building for multiple ABIs
enable true
// Include x86/x86_64 APKs
include "x86", "x86_64"
universalApk false
}
}
buildToolsVersion '33.0.1'
}
kotlin {
jvmToolchain(8)
}
dependencies {
implementation "androidx.compose.material3:material3:1.1.1"
implementation "androidx.core:core-ktx:${kotlin_version}"
// Kordophone lib
implementation project(':backend')
implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// Navigation
def nav_version = "2.6.0"
// Java language implementation
implementation "androidx.navigation:navigation-fragment:$nav_version"
implementation "androidx.navigation:navigation-ui:$nav_version"
// Kotlin
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
// Feature module Support
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
// Testing Navigation
androidTestImplementation "androidx.navigation:navigation-testing:$nav_version"
// Jetpack Compose Integration
implementation "androidx.navigation:navigation-compose:$nav_version"
// Jetpack Compose
def compose_version = "1.4.3"
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.foundation:foundation:$compose_version"
implementation "androidx.activity:activity-compose:$compose_version"
// Lifecycle
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
// Hilt (dependency injection)
implementation "com.google.dagger:hilt-android:${hilt_version}"
implementation "androidx.hilt:hilt-navigation-compose:1.0.0"
kapt "com.google.dagger:hilt-compiler:${hilt_version}"
// Coil (image loading library)
implementation "io.coil-kt:coil:2.4.0"
implementation "io.coil-kt:coil-compose:2.4.0"
// Disk LRU Cache
implementation "com.jakewharton:disklrucache:2.0.2"
// Zooming in images
implementation "net.engawapg.lib:zoomable:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}
// Allow references to generated code
kapt {
correctErrorTypes true
}

21
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,137 @@
package previews
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListItem
import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListScreen
import net.buzzert.kordophonedroid.ui.conversationlist.NoContentView
import net.buzzert.kordophonedroid.ui.messagelist.AttachmentRowItem
import net.buzzert.kordophonedroid.ui.messagelist.MessageEntry
import net.buzzert.kordophonedroid.ui.messagelist.MessageListItem
import net.buzzert.kordophonedroid.ui.messagelist.MessageMetadata
import net.buzzert.kordophonedroid.ui.messagelist.MessageTranscript
import net.buzzert.kordophonedroid.ui.settings.SettingsScreen
import java.util.Date
// - Conversation List
@Preview
@Composable
fun ConversationListItemPreview() {
Column(modifier = Modifier.background(MaterialTheme.colors.background)) {
ConversationListItem(name = "James Magahern", id = "asdf", lastMessagePreview = "This is a test", date = Date(), isUnread = true) {}
}
}
@Preview
@Composable
fun ConversationListScreenPreview() {
ConversationListScreen()
}
// - Message List
private fun testMessageMetadata(fromMe: Boolean, delivered: Boolean): MessageMetadata {
return MessageMetadata(
fromMe = fromMe,
fromAddress = if (fromMe) "<me>" else "cool@cool.com",
date = Date(),
delivered = delivered,
)
}
private fun makeTestTextMessageItem(text: String, fromMe: Boolean, delivered: Boolean = true): MessageListItem {
return MessageListItem.TextMessage(
text = text,
metadata = testMessageMetadata(fromMe = fromMe, delivered = delivered)
)
}
private fun makeTestImageMessageItem(fromMe: Boolean, delivered: Boolean = true): MessageListItem {
return MessageListItem.ImageAttachmentMessage(
guid = "asdf",
metadata = testMessageMetadata(fromMe, delivered)
)
}
@Preview
@Composable
private fun MessageListScreenPreview() {
val messages = listOf<MessageListItem>(
makeTestImageMessageItem(false),
makeTestTextMessageItem("Hello", false),
makeTestTextMessageItem( "Hey there, this is a longer text message that might wrap to another line", true),
makeTestTextMessageItem("How's it going", fromMe = true, delivered = false)
).reversed()
Scaffold() {
MessageTranscript(
messages = messages,
paddingValues = it,
showSenders = true,
attachmentUris = setOf(),
onAddAttachment = {},
onClearAttachments = {},
onSendMessage = {}
)
}
}
@Preview(showBackground = true)
@Composable
private fun MessageEntryPreview() {
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue("Hello this is some text that might wrap multiple lines to show that there must be some padding here. "))
}
MessageEntry(onSend = {}, onTextChanged = {}, textFieldValue = textState)
}
@Preview(showBackground = true)
@Composable
private fun MessageEntryWithAttachmentsPreview() {
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue("Attachments"))
}
MessageEntry(onSend = {}, onTextChanged = {}, textFieldValue = textState, attachmentItems = listOf(
AttachmentRowItem(painterResource(id = R.drawable.sedona), "id")
))
}
// - No content
@Preview
@Composable
fun NoContentPreview() {
Scaffold {
NoContentView(
icon = R.drawable.storage,
text = "Server not configured",
onSettings = {},
modifier = Modifier.padding(it)
)
}
}
// - Settings
@Preview
@Composable
fun SettingsPreview() {
SettingsScreen()
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.KordophoneDroid"
android:name=".KordophoneApplication"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
>
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.KordophoneDroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<!-- Message List Deep Link -->
<data android:scheme="kordophone" android:host="messages" />
</intent-filter>
<meta-data
android:name="android.app.lib_name"
android:value="" />
</activity>
<service
android:name=".UpdateMonitorService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,41 @@
package net.buzzert.kordophonedroid
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import net.buzzert.kordophone.backend.db.CachedChatDatabase
import net.buzzert.kordophone.backend.server.APIClientFactory
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.attachments.AttachmentImageLoader
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Singleton
@Provides
fun provideChatRepository(configRepository: ServerConfigRepository): ChatRepository {
val serverConfig = configRepository.serverConfig.value
val server = serverConfig.serverName
val authentication = serverConfig.authentication?.toBackendAuthentication()
val client = APIClientFactory.createClient(server, authentication)
val database = CachedChatDatabase.liveDatabase()
return ChatRepository(client, database)
}
@Singleton
@Provides
fun provideAttachmentFactory(
chatRepository: ChatRepository,
@ApplicationContext context: Context
): AttachmentImageLoader
{
return AttachmentImageLoader(chatRepository, context)
}
}

View File

@@ -0,0 +1,13 @@
package net.buzzert.kordophonedroid
import android.app.Application
import androidx.hilt.navigation.compose.hiltViewModel
import dagger.hilt.android.HiltAndroidApp
import net.buzzert.kordophonedroid.data.AppContainer
@HiltAndroidApp
class KordophoneApplication : Application() {
override fun onCreate() {
super.onCreate()
}
}

View File

@@ -0,0 +1,33 @@
package net.buzzert.kordophonedroid
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.app.ActivityCompat
import dagger.hilt.android.AndroidEntryPoint
import net.buzzert.kordophonedroid.ui.KordophoneApp
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Ask for notifications
val hasPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
if (hasPermission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1234)
}
// Start update monitor service
val intent = Intent(this, UpdateMonitorService::class.java)
startService(intent)
setContent {
KordophoneApp()
}
}
}

View File

@@ -0,0 +1,161 @@
package net.buzzert.kordophonedroid
import android.Manifest
import android.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.IBinder
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import androidx.navigation.NavDeepLinkBuilder
import androidx.navigation.NavDeepLinkRequest
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.Destination
import javax.inject.Inject
const val PUSH_CHANNEL_ID = "net.buzzert.kordophone.persistentNotification"
const val NEW_MESSAGE_CHANNEL_ID = "net.buzzert.kordophone.newMessage"
const val UPDATER_LOG = "UpdateService"
@AndroidEntryPoint
class UpdateMonitorService: Service()
{
@Inject lateinit var chatRepository: ChatRepository
private var newMessageID: Int = 0
private var watchJob: Job? = null
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
private fun createNotificationChannel(channelId: String, channelName: String) {
val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
chan.lockscreenVisibility = Notification.VISIBILITY_PRIVATE
chan.lightColor = Color.BLUE
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(chan)
}
override fun onCreate() {
super.onCreate()
Log.v(UPDATER_LOG, "UpdateMonitor onCreate: Begin watching for updates.")
createNotificationChannel(NEW_MESSAGE_CHANNEL_ID, "New Messages")
// Connect to monitor
chatRepository.beginWatchingForUpdates(scope)
// Connect to new message flow for notifications
watchJob?.cancel()
watchJob = scope.launch {
chatRepository.newMessages.collectLatest(::onReceiveNewMessage)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel(PUSH_CHANNEL_ID, "Update Monitor Service")
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0, notificationIntent, PendingIntent.FLAG_IMMUTABLE
)
val notification: Notification = NotificationCompat.Builder(this, PUSH_CHANNEL_ID)
.setContentTitle("Kordophone Connected")
.setContentText("Kordophone is listening for new messages.")
.setSmallIcon(R.drawable.sym_action_chat)
.setContentIntent(pendingIntent)
.setShowWhen(false)
.setSilent(true)
.setOngoing(true)
.build()
startForeground(5738, notification)
// Restart if we get killed
return START_STICKY
}
private fun onReceiveNewMessage(message: Message) {
val hasPermission = ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
if (hasPermission != PackageManager.PERMISSION_GRANTED) {
Log.e(UPDATER_LOG, "No permissions to post notifications.")
return
}
if (message.conversation.unreadCount == 0) {
// Not unread.
Log.v(UPDATER_LOG, "Ignoring read message.")
return
}
if (message.sender == null) {
// From me.
Log.v(UPDATER_LOG, "Ignoring message from me.")
return
}
if (message.conversation.isGroupChat) {
// For now, since these can be noisy and there's no UI for changing it, ignore group chats.
Log.v(UPDATER_LOG, "Ignoring group chat message.")
return
}
val guid = message.conversation.guid
val deepLinkIntent = Intent(
Intent.ACTION_VIEW,
"kordophone://messages/$guid".toUri(),
this,
MainActivity::class.java
)
val pendingIntent = PendingIntent.getActivity(this, 0, deepLinkIntent, PendingIntent.FLAG_IMMUTABLE)
val groupId = message.conversation.guid
val notification = NotificationCompat.Builder(this, NEW_MESSAGE_CHANNEL_ID)
.setContentTitle(message.sender)
.setContentText(message.text)
.setSmallIcon(R.drawable.stat_notify_chat)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setGroup(groupId)
.build()
val manager = NotificationManagerCompat.from(this)
manager.notify(newMessageID++, notification)
}
override fun onBind(intent: Intent?): IBinder? {
// no binding
return null
}
override fun onDestroy() {
super.onDestroy()
chatRepository.stopWatchingForUpdates()
job.cancel()
}
}

View File

@@ -0,0 +1,15 @@
package net.buzzert.kordophonedroid.data
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import dagger.hilt.android.lifecycle.HiltViewModel
import net.buzzert.kordophone.backend.server.ChatRepository
import javax.inject.Inject
@HiltViewModel
class AppContainer @Inject constructor(
val repository: ChatRepository
) : ViewModel() {
}

View File

@@ -0,0 +1,110 @@
package net.buzzert.kordophonedroid.ui
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHost
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import kotlinx.coroutines.flow.collectLatest
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.data.AppContainer
import net.buzzert.kordophonedroid.ui.attachments.AttachmentViewer
import net.buzzert.kordophonedroid.ui.theme.KordophoneTheme
import net.buzzert.kordophonedroid.ui.conversationlist.ConversationListScreen
import net.buzzert.kordophonedroid.ui.messagelist.MessageListScreen
import net.buzzert.kordophonedroid.ui.settings.SettingsScreen
sealed class Destination(val route: String) {
object ConversationList : Destination("conversations")
object Settings : Destination("settings")
object MessageList : Destination("messages/{id}") {
fun createRoute(data: String) = "messages/$data"
}
object AttachmentViewer : Destination("attachment/{guid}") {
fun createRoute(guid: String) = "attachment/$guid"
}
}
val LocalNavController = compositionLocalOf<NavHostController> { error("No nav host") }
@Composable
fun ErrorDialog(title: String, body: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = { onDismiss() },
title = { Text(title) },
text = { Text(body) },
confirmButton = {
Button(onClick = { onDismiss() }) {
Text("OK")
}
}
)
}
@Composable
fun KordophoneApp(
appContainer: AppContainer = hiltViewModel(),
) {
KordophoneTheme {
val navController = rememberNavController()
val errorVisible = remember { mutableStateOf<ChatRepository.Error?>(null) }
val error = appContainer.repository.errorEncounteredChannel.collectAsStateWithLifecycle(
initialValue = null
)
LaunchedEffect(key1 = error.value) {
errorVisible.value = error.value
}
CompositionLocalProvider(LocalNavController provides navController) {
NavHost(
navController = navController,
startDestination = Destination.ConversationList.route,
) {
composable(Destination.ConversationList.route) {
ConversationListScreen()
}
composable(
route = Destination.MessageList.route,
deepLinks = listOf(navDeepLink { uriPattern = "kordophone://messages/{id}" })
) {
val conversationID = it.arguments?.getString("id")!!
MessageListScreen(conversationGUID = conversationID)
}
composable(Destination.Settings.route) {
SettingsScreen()
}
composable(Destination.AttachmentViewer.route) {
val guid = it.arguments?.getString("guid")!!
AttachmentViewer(attachmentGuid = guid)
}
}
}
errorVisible.value?.let {
ErrorDialog(title = it.title, body = it.description) {
errorVisible.value = null
}
}
}
}

View File

@@ -0,0 +1,116 @@
package net.buzzert.kordophonedroid.ui.attachments
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import coil.Coil
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.annotation.ExperimentalCoilApi
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.disk.DiskCache
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import dagger.hilt.android.qualifiers.ApplicationContext
import net.buzzert.kordophone.backend.server.ChatRepository
const val AVM_LOG: String = "AttachmentImageLoader"
data class AttachmentFetchData(
val guid: String,
val preview: Boolean = false
)
class AttachmentImageLoader(
private val repository: ChatRepository,
@ApplicationContext val application: Context,
) : ViewModel(), ImageLoaderFactory, Fetcher.Factory<AttachmentFetchData>
{
init {
// Register Coil image loader
Coil.setImageLoader(this)
}
override fun newImageLoader(): ImageLoader {
val factory = this
return ImageLoader.Builder(application)
.components {
// Adds the FetcherFactory
add(factory)
}
.build()
}
override fun create(
data: AttachmentFetchData,
options: Options,
imageLoader: ImageLoader
): Fetcher {
return AttachmentFetcher(repository, application, data)
}
}
private class AttachmentFetcher(
val repository: ChatRepository,
val context: Context,
val data: AttachmentFetchData
): Fetcher {
val cache = DiskCache.Builder()
.directory(context.cacheDir.resolve("attachments"))
.maxSizePercent(0.02)
.build()
val cacheKey: String get() { return data.guid + if (data.preview) "_preview" else "" }
@OptIn(ExperimentalCoilApi::class)
override suspend fun fetch(): FetchResult {
// Try loading from cache
var snapshot = cache.openSnapshot(cacheKey)
if (snapshot != null) {
Log.d(AVM_LOG, "Found attachment ${data.guid} in disk cache")
return SourceResult(
source = snapshot.toImageSource(),
dataSource = DataSource.DISK,
mimeType = null,
)
}
Log.d(AVM_LOG, "Loading attachment ${data.guid} from network")
val source = repository.fetchAttachmentDataSource(data.guid, data.preview)
// Save to cache
val editor = cache.openEditor(cacheKey)
if (editor != null) {
Log.d(AVM_LOG, "Writing attachment ${data.guid} to disk cache")
cache.fileSystem.write(editor.data) {
source.readAll(this)
}
snapshot = editor.commitAndOpenSnapshot()
if (snapshot != null) {
return SourceResult(
source = snapshot.toImageSource(),
dataSource = DataSource.NETWORK,
mimeType = null
)
}
}
// We'll go down this path if for some reason we couldn't save to cache.
return SourceResult(
source = ImageSource(source, context),
dataSource = DataSource.NETWORK,
mimeType = null,
)
}
@OptIn(ExperimentalCoilApi::class)
private fun DiskCache.Snapshot.toImageSource(): ImageSource {
val fileSystem = cache.fileSystem
return ImageSource(data, fileSystem, cacheKey, this)
}
}

View File

@@ -0,0 +1,70 @@
package net.buzzert.kordophonedroid.ui.attachments
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import net.buzzert.kordophonedroid.ui.LocalNavController
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@Composable
fun AttachmentViewer(attachmentGuid: String) {
var topBarVisible = remember { mutableStateOf(true) }
val navController = LocalNavController.current
Scaffold(topBar = {
KordophoneTopAppBar(
title = "Attachment",
backAction = { navController.popBackStack() },
visible = topBarVisible.value
)
}) { padding ->
val zoomState = rememberZoomState()
val interactionSource = remember { MutableInteractionSource() }
val data = AttachmentFetchData(attachmentGuid, preview = false)
Column(modifier = Modifier.padding(padding)) {
Spacer(modifier = Modifier.weight(1f))
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(data)
.crossfade(true)
.build(),
contentDescription = "",
loading = {
Box {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
},
modifier = Modifier
.zoomable(zoomState)
.fillMaxWidth()
.align(Alignment.CenterHorizontally)
.clickable(
interactionSource = interactionSource,
indication = null
) {
topBarVisible.value = !topBarVisible.value
}
)
Spacer(modifier = Modifier.weight(1f))
}
}
}

View File

@@ -0,0 +1,248 @@
package net.buzzert.kordophonedroid.ui.conversationlist
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.Destination
import net.buzzert.kordophonedroid.ui.LocalNavController
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
fun formatDateTime(dateTime: LocalDateTime): String {
val formatter: DateTimeFormatter = if (LocalDate.now().isEqual(dateTime.toLocalDate())) {
DateTimeFormatter.ofPattern("HH:mm") // show just the time
} else {
DateTimeFormatter.ofPattern("M/d/yy") // show day/month/year
}
return dateTime.format(formatter)
}
@Composable
fun ConversationListScreen(
viewModel: ConversationListViewModel = hiltViewModel(),
) {
val conversations by viewModel.conversations.collectAsStateWithLifecycle(initialValue = emptyList())
val encounteredError by viewModel.encounteredConnectionError
ConversationListView(
conversations = conversations,
isConfigured = viewModel.isServerConfigured,
encounteredError = encounteredError,
onRefresh = suspend { viewModel.refresh() }
)
}
@Composable
@OptIn(ExperimentalMaterialApi::class)
fun ConversationListView(
conversations: List<Conversation>,
isConfigured: Boolean = true,
encounteredError: Boolean = false,
onRefresh: suspend () -> Unit,
) {
val listState = rememberLazyListState()
val refreshScope = rememberCoroutineScope()
var refreshing by remember { mutableStateOf(false) }
fun refresh() = refreshScope.launch {
refreshing = true
onRefresh()
refreshing = false
}
val showErrorScreen = conversations.isEmpty() && encounteredError
val refreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = ::refresh)
val navController = LocalNavController.current
val onSettingsInvoked = { navController.navigate(Destination.Settings.route) }
Scaffold(
topBar = {
TopAppBar(title = { Text("Conversations") }, actions = {
IconButton(onClick = onSettingsInvoked) {
Icon(Icons.Rounded.Settings, contentDescription = "Settings")
}
})
}
) {
if (showErrorScreen) {
NoContentView(
icon = R.drawable.error,
text = "Connection error",
onSettings = onSettingsInvoked
)
} else if (!isConfigured) {
NoContentView(
icon = R.drawable.storage,
text = "Server not configured",
onSettings = onSettingsInvoked
)
} else {
Box(Modifier.pullRefresh(refreshState)) {
LazyColumn(
state = listState,
modifier = Modifier
.padding(it)
.fillMaxSize()
) {
items(conversations) { conversation ->
val clickHandler = {
val route = Destination.MessageList.createRoute(conversation.guid)
navController.navigate(route)
}
ConversationListItem(
name = conversation.formattedDisplayName(),
id = conversation.guid,
isUnread = conversation.unreadCount > 0,
lastMessagePreview = conversation.lastMessagePreview ?: "",
date = conversation.date,
onClick = clickHandler
)
}
}
PullRefreshIndicator(
refreshing = refreshing,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
}
}
@Composable
fun ConversationListItem(
name: String,
id: String,
isUnread: Boolean,
lastMessagePreview: String,
date: Date,
onClick: () -> Unit
) {
val unreadSize = 12.dp
val horizontalPadding = 8.dp
val verticalPadding = 14.dp
Row(
Modifier
.clickable(onClick = onClick)
) {
Spacer(Modifier.width(horizontalPadding))
// Unread icon
if (isUnread) {
UnreadIndicator(
size = unreadSize,
modifier = Modifier.align(Alignment.CenterVertically)
)
} else {
Spacer(modifier = Modifier.size(unreadSize))
}
Spacer(Modifier.width(horizontalPadding))
Column {
Spacer(Modifier.height(verticalPadding))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
name,
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.weight(1f, fill = true)
)
Spacer(modifier = Modifier)
Text(
formatDateTime(
date.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
),
color = MaterialTheme.colors.onBackground.copy(alpha = 0.4f),
maxLines = 1,
modifier = Modifier
.align(Alignment.CenterVertically)
,
)
Spacer(Modifier.width(horizontalPadding))
}
Text(lastMessagePreview, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(verticalPadding))
Divider()
}
}
}
@Composable
fun UnreadIndicator(size: Dp, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.size(size)
.background(
color = MaterialTheme.colors.primary,
shape = CircleShape
)
)
}

View File

@@ -0,0 +1,74 @@
package net.buzzert.kordophonedroid.ui.conversationlist
import android.util.Log
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.server.APIClientFactory
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
import javax.inject.Inject
const val CL_VM_LOG: String = "ConversationListViewModel"
@HiltViewModel
class ConversationListViewModel @Inject constructor(
private val chatRepository: ChatRepository,
private val serverConfigRepository: ServerConfigRepository,
) : ViewModel() {
val conversations: Flow<List<Conversation>>
get() = chatRepository.conversationChanges
.shareIn(viewModelScope, started = SharingStarted.WhileSubscribed())
.map {
it.sortedBy { it.date }
.reversed()
}
val isServerConfigured: Boolean
get() = chatRepository.isConfigured
val encounteredConnectionError: State<Boolean>
get() = _encounteredConnectionError
private val _encounteredConnectionError = mutableStateOf(false)
init {
// Watch for config changes
viewModelScope.launch {
serverConfigRepository.serverConfig.collect { config ->
Log.d(CL_VM_LOG, "Got settings change.")
// Make new APIClient
val baseURL = config.serverName
val authentication = config.authentication?.toBackendAuthentication()
val apiClient = APIClientFactory.createClient(baseURL, authentication)
chatRepository.updateAPIClient(apiClient)
// Perform db synchronization
withContext(Dispatchers.IO) {
chatRepository.synchronize()
}
}
}
viewModelScope.launch {
chatRepository.errorEncounteredChannel.collect {
_encounteredConnectionError.value = true
}
}
}
suspend fun refresh() {
chatRepository.synchronize()
}
}

View File

@@ -0,0 +1,61 @@
package net.buzzert.kordophonedroid.ui.conversationlist
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
@Composable
fun NoContentView(
@DrawableRes icon: Int,
text: String,
onSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
painter = painterResource(icon),
"server icon",
modifier = Modifier
.height(150.dp)
.width(150.dp)
.alpha(0.5F)
)
Spacer(Modifier)
Text(
text = text,
fontSize = 5.0.em,
modifier = Modifier
.alpha(0.5F)
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onSettings) {
Text("Settings")
}
}
}

View File

@@ -0,0 +1,166 @@
package net.buzzert.kordophonedroid.ui.messagelist
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.ElevatedButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import net.buzzert.kordophonedroid.R
data class AttachmentRowItem(
val painter: Painter,
val id: String,
)
@Composable
fun AttachmentRow(
attachmentItems: List<AttachmentRowItem>,
onClear: () -> Unit,
) {
Divider()
Row(
modifier = Modifier
.height(120.dp)
.fillMaxWidth()
.background(MaterialTheme.colors.onSurface.copy(0.08f))
.padding(8.dp)
) {
LazyRow {
attachmentItems.forEach { attachmentItem ->
item {
Image(
painter = attachmentItem.painter,
contentDescription = "attachment",
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(1.0f)
.clip(RoundedCornerShape(4.dp))
)
Spacer(Modifier.width(4.dp))
}
}
}
Spacer(Modifier.weight(1f))
ElevatedButton(
onClick = onClear,
colors = ButtonDefaults.elevatedButtonColors(
containerColor = MaterialTheme.colors.background
),
modifier = Modifier.align(Alignment.CenterVertically)
) {
Text("Remove")
}
}
}
@Composable
fun MessageEntry(
textFieldValue: TextFieldValue,
attachmentItems: List<AttachmentRowItem> = listOf(),
onAddAttachment: () -> Unit = {},
onClearAttachments: () -> Unit = {},
onTextChanged: (TextFieldValue) -> Unit,
onSend: () -> Unit,
) {
Column {
if (attachmentItems.isNotEmpty()) {
AttachmentRow(attachmentItems, onClear = onClearAttachments)
}
Row(
modifier = Modifier
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.18f))
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 4.dp)
.imePadding()
.navigationBarsPadding()
) {
IconButton(
onClick = onAddAttachment,
) {
Icon(
painter = painterResource(id = R.drawable.attach_file),
contentDescription = "Attach File"
)
}
Spacer(Modifier.width(8.dp))
Surface(
shape = MaterialTheme.shapes.medium,
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.shadow(3.dp)
) {
BasicTextField(
value = textFieldValue,
onValueChange = { onTextChanged(it) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
cursorBrush = SolidColor(MaterialTheme.colors.onBackground),
textStyle = MaterialTheme.typography.body1.copy(MaterialTheme.colors.onBackground),
decorationBox = { textContent ->
if (textFieldValue.text.isEmpty()) {
Text(
text = "Message",
style = MaterialTheme.typography.body1.copy(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
)
)
}
textContent()
}
)
}
Spacer(Modifier.width(8.dp))
Button(
onClick = onSend,
enabled = (attachmentItems.isNotEmpty() || textFieldValue.text.isNotEmpty())
) {
Text(text = "Send")
}
Spacer(Modifier.width(8.dp))
}
}
}

View File

@@ -0,0 +1,433 @@
package net.buzzert.kordophonedroid.ui.messagelist
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.SubcomposeAsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophonedroid.ui.Destination
import net.buzzert.kordophonedroid.ui.LocalNavController
import net.buzzert.kordophonedroid.ui.attachments.AttachmentFetchData
import net.buzzert.kordophonedroid.ui.shared.LINK_ANNOTATION_TAG
import net.buzzert.kordophonedroid.ui.shared.linkify
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
import java.text.SimpleDateFormat
import java.time.Duration
import java.util.Date
private val IncomingChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
private val OutgoingChatBubbleShape = RoundedCornerShape(20.dp, 4.dp, 20.dp, 20.dp)
data class MessageMetadata(
val fromAddress: String,
val fromMe: Boolean,
val date: Date,
val delivered: Boolean = true,
)
interface MessageMetadataProvider {
val metadata: MessageMetadata
}
sealed class MessageListItem: MessageMetadataProvider {
data class TextMessage(val text: String, override val metadata: MessageMetadata): MessageListItem()
data class ImageAttachmentMessage(val guid: String, override val metadata: MessageMetadata): MessageListItem()
}
@Composable
fun MessageListScreen(
conversationGUID: GUID,
viewModel: MessageListViewModel = hiltViewModel(),
) {
viewModel.conversationGUID = conversationGUID
// Synchronize on launch
val context = LocalContext.current
LaunchedEffect(Unit) {
// Clear notifications for this conversation
with(NotificationManagerCompat.from(context)) {
// Not sure how to cancel individual notifications, or groups yet...
cancelAll()
}
viewModel.markAsRead()
viewModel.synchronize()
}
val messages by viewModel.messages.collectAsStateWithLifecycle(initialValue = listOf())
val messageItems = mutableListOf<MessageListItem>()
for (message in messages) {
val metadata = MessageMetadata(
fromMe = message.sender == null,
date = message.date,
fromAddress = message.sender ?: "<me>",
delivered = !viewModel.isPendingMessage(message)
)
// Collect attachments
message.attachmentGUIDs?.let { guids ->
guids.forEach { guid ->
val item = MessageListItem.ImageAttachmentMessage(
guid = guid,
metadata = metadata
)
messageItems.add(item)
}
}
val displayText = message.displayText.trim()
if (displayText.isNotEmpty()) {
val textMessage = MessageListItem.TextMessage(text = displayText, metadata = metadata)
messageItems.add(textMessage)
}
}
var attachmentUris by remember { mutableStateOf<Set<Uri>>(mutableSetOf()) }
val imagePicker = rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? ->
uri?.let {
attachmentUris = attachmentUris.plus(it)
}
}
val navController = LocalNavController.current
Scaffold(
topBar = {
KordophoneTopAppBar(title = viewModel.title, backAction = { navController.popBackStack() })
}) { padding ->
MessageTranscript(
messages = messageItems,
paddingValues = padding,
showSenders = viewModel.isGroupChat,
attachmentUris = attachmentUris,
onAddAttachment = {
imagePicker.launch("image/*")
},
onClearAttachments = {
attachmentUris = setOf()
},
onSendMessage = { text ->
viewModel.enqueueOutgoingMessage(
text = text,
attachmentUris = attachmentUris,
context = context
)
// Clear pending attachments
attachmentUris = setOf()
}
)
}
}
@Composable
fun MessageTranscript(
messages: List<MessageListItem>,
paddingValues: PaddingValues,
showSenders: Boolean,
attachmentUris: Set<Uri>,
onAddAttachment: () -> Unit,
onClearAttachments: () -> Unit,
onSendMessage: (text: String) -> Unit,
) {
val scrollState = rememberLazyListState()
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
mutableStateOf(TextFieldValue())
}
val attachmentRowItems = attachmentUris.map {
AttachmentRowItem(
painter = rememberAsyncImagePainter(model = it),
id = "attachmentID"
)
}
Column(
Modifier
.fillMaxSize()
.padding(paddingValues)) {
Messages(
messages = messages,
modifier = Modifier.weight(1f),
showSenders = showSenders,
scrollState = scrollState
)
MessageEntry(
onTextChanged = { textState = it },
textFieldValue = textState,
attachmentItems = attachmentRowItems,
onAddAttachment = onAddAttachment,
onClearAttachments = onClearAttachments,
onSend = {
onSendMessage(textState.text)
// Clear text state
textState = TextFieldValue()
},
)
}
}
@Composable
fun Messages(
messages: List<MessageListItem>,
showSenders: Boolean,
modifier: Modifier = Modifier,
scrollState: LazyListState
) {
Box(modifier = modifier) {
LazyColumn(
reverseLayout = true,
state = scrollState,
contentPadding = PaddingValues(vertical = 8.dp),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
val dateFormatter = SimpleDateFormat.getDateTimeInstance()
for (index in messages.indices) {
val content = messages[index]
var previousMessage: MessageListItem? = null
if ((index + 1) < messages.count()) {
previousMessage = messages[index + 1]
}
val duration: Duration? = if (previousMessage != null) Duration.between(
previousMessage.metadata.date.toInstant(),
content.metadata.date.toInstant()
) else null
val leapMessage = (
duration == null || (
duration.toMinutes() > 30
)
)
val repeatMessage = !leapMessage && (
previousMessage == null || (
(previousMessage.metadata.fromAddress == content.metadata.fromAddress)
)
)
// Remember: This is upside down.
item {
when (content) {
is MessageListItem.TextMessage -> {
MessageBubble(
text = content.text,
mine = content.metadata.fromMe,
modifier = Modifier
.alpha(if (!content.metadata.delivered) 0.5F else 1.0f)
)
}
is MessageListItem.ImageAttachmentMessage -> {
ImageBubble(guid = content.guid, mine = content.metadata.fromMe)
}
}
// Sender
if (!content.metadata.fromMe && showSenders && !repeatMessage) {
Text(
text = content.metadata.fromAddress,
style = MaterialTheme.typography.subtitle2.copy(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f)
),
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Greater than 30 minutes: show date:
if (duration != null) {
if (duration.toMinutes() > 30) {
val formattedDate = dateFormatter.format(content.metadata.date)
Text(
text = formattedDate,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption.copy(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f)
),
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
)
}
// Greater than five minutes: add a bit of space.
else if (duration.toMinutes() > 5) {
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
}
}
}
@Composable
fun BubbleScaffold(
mine: Boolean,
modifier: Modifier,
content: @Composable () -> Unit
) {
Column() {
Row(modifier = modifier.fillMaxWidth()) {
if (mine) {
Spacer(modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(0.8f),
horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start,
) {
content()
}
if (!mine) { Spacer(modifier = Modifier.weight(1f)) }
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@Composable
fun MessageBubble(
text: String,
mine: Boolean,
modifier: Modifier = Modifier,
) {
val backgroundBubbleColor = if (mine) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
// Linkify text
val annotatedString = text.linkify()
val urlHandler = LocalUriHandler.current
BubbleScaffold(mine = mine, modifier = modifier) {
Surface(
color = backgroundBubbleColor,
shape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape,
) {
ClickableText(
text = annotatedString,
style = MaterialTheme.typography.body2,
modifier = Modifier
.padding(12.dp),
onClick = { index ->
annotatedString
.getStringAnnotations(LINK_ANNOTATION_TAG, index, index)
.firstOrNull()?.let {
urlHandler.openUri(it.item)
}
}
)
}
}
}
@Composable
fun ImageBubble(
guid: String,
mine: Boolean,
modifier: Modifier = Modifier,
) {
val shape: RoundedCornerShape = if (mine) OutgoingChatBubbleShape else IncomingChatBubbleShape
val attachmentFetchData = AttachmentFetchData(guid, preview = true)
val navController = LocalNavController.current
BubbleScaffold(mine = mine, modifier = modifier) {
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(attachmentFetchData)
.crossfade(true)
.build(),
loading = {
Box(
modifier = Modifier
.background(Color.LightGray)
.size(width = 220.dp, height = 200.dp)
) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
},
error = {
val error = it.result.throwable.message
Surface(
color = Color.Red
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = "Error loading attachment",
style = TextStyle.Default.copy(
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
)
Text("$error")
}
}
},
contentDescription = "Image attachment",
modifier = Modifier
.clip(shape)
.clickable {
navController.navigate(Destination.AttachmentViewer.createRoute(guid))
}
)
}
}

View File

@@ -0,0 +1,115 @@
package net.buzzert.kordophonedroid.ui.messagelist
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.OutgoingMessage
import net.buzzert.kordophone.backend.model.UploadingAttachmentMetadata
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophonedroid.ui.attachments.AttachmentImageLoader
import javax.inject.Inject
const val MVM_LOG: String = "MessageListViewModel"
@HiltViewModel
class MessageListViewModel @Inject constructor(
private val repository: ChatRepository,
private val imageLoader: AttachmentImageLoader
) : ViewModel()
{
var conversationGUID: GUID? = null
set(value) {
field = value
value?.let {
conversation = repository.conversationForGuid(it)
}
}
private var conversation: Conversation? = null
private val pendingMessages: MutableStateFlow<List<OutgoingMessage>> = MutableStateFlow(listOf())
init {
// TODO: Need to handle settings changes here!!
viewModelScope.launch {
// Remove pending message after message is delivered.
// By now, the repository should've committed this to the store.
repository.messageDeliveredChannel.collectLatest { event ->
pendingMessages.value =
pendingMessages.value.filter { it.guid != event.requestGuid }
}
}
}
val messages: Flow<List<Message>>
get() = repository.messagesChanged(conversation!!)
.combine(pendingMessages) { a, b -> a.union(b.map { it.asMessage() }) }
.map { messages ->
messages
.sortedBy { it.date }
.reversed()
}
val title: String get() = conversation!!.formattedDisplayName()
val isGroupChat: Boolean get() = conversation!!.isGroupChat
fun enqueueOutgoingMessage(
text: String,
attachmentUris: Set<Uri>,
context: Context,
) {
val outgoingMessage = OutgoingMessage(
body = text,
conversation = conversation!!,
attachmentUris = attachmentUris,
attachmentDataSource = { uri ->
val resolver = context.contentResolver
val inputStream = resolver.openInputStream(uri)
val mimeType = resolver.getType(uri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: "jpg"
val filename = uri.lastPathSegment + ".$extension"
if (inputStream != null && mimeType != null) {
UploadingAttachmentMetadata(
inputStream = inputStream,
mimeType = mimeType,
filename = filename
)
} else {
null
}
}
)
val outgoingGUID = repository.enqueueOutgoingMessage(outgoingMessage)
pendingMessages.value = pendingMessages.value + listOf(outgoingMessage)
}
fun isPendingMessage(message: Message): Boolean {
return pendingMessages.value.any { it.guid == message.guid }
}
fun markAsRead() = viewModelScope.launch {
repository.markConversationAsRead(conversation!!)
}
fun synchronize() = viewModelScope.launch {
repository.synchronizeConversation(conversation!!, limit = 100)
}
}

View File

@@ -0,0 +1,226 @@
package net.buzzert.kordophonedroid.ui.settings
import android.provider.Settings
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import net.buzzert.kordophonedroid.R
import net.buzzert.kordophonedroid.ui.LocalNavController
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
) {
val navController = LocalNavController.current
Scaffold(
topBar = {
KordophoneTopAppBar(
title = "Settings",
backAction = { navController.popBackStack() },
)
},
) {
SettingsFormView(
viewModel = viewModel,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(it)
.padding(6.dp)
)
}
}
@Composable
fun SettingsFormView(
viewModel: SettingsViewModel,
modifier: Modifier = Modifier
) {
val serverName = viewModel.serverPreference.collectAsState()
val userName = viewModel.usernamePreference.collectAsState()
val password = viewModel.passwordPreference.collectAsState()
Column(modifier) {
var serverNameInput by remember { mutableStateOf(TextFieldValue(serverName.value)) }
SettingsTextField(
name = "Server",
icon = R.drawable.storage,
state = serverName,
onSave = { viewModel.saveServerPreference(serverNameInput.text) }
) { state ->
TextField(serverNameInput, onValueChange = {
serverNameInput = it
})
}
var usernameInput by remember { mutableStateOf(TextFieldValue(userName.value)) }
var passwordInput by remember { mutableStateOf(TextFieldValue(password.value)) }
SettingsTextField(
name = "Authentication",
icon = R.drawable.account_circle,
state = userName,
onSave = {
viewModel.saveAuthenticationPreferences(usernameInput.text, passwordInput.text)
}
) {
Column() {
TextField(
value = usernameInput,
onValueChange = { usernameInput = it },
label = { Text("Username") },
)
TextField(
value = passwordInput,
onValueChange = { passwordInput = it },
label = {Text("Password") },
visualTransformation = PasswordVisualTransformation(),
)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterialApi::class)
fun <T> SettingsTextField(
name: String,
@DrawableRes icon: Int,
state: State<T>,
onSave: () -> Unit,
dialogContent: @Composable (State<T>) -> Unit,
) {
var showingDialog by remember { mutableStateOf(false) }
if (showingDialog) {
Dialog(
onDismissRequest = { showingDialog = false }
) {
EditDialog(
name = name,
onDismiss = {
onSave()
showingDialog = false
},
content = { dialogContent(state) }
)
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
,
onClick = {
showingDialog = true
},
) {
val valueString = state.value.toString().ifEmpty { "(Not set)" }
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.padding(vertical = 8.dp)
) {
Icon(
painterResource(id = icon),
contentDescription = "",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.padding(8.dp)) {
// Title
Text(
text = name,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Start,
)
Spacer(modifier = Modifier.height(4.dp))
// Value
Text(
text = valueString,
style = MaterialTheme.typography.body2,
textAlign = TextAlign.Start,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
)
}
}
Divider()
}
}
}
@Composable
private fun EditDialog(
name: String,
onDismiss: () -> Unit,
content: @Composable () -> Unit,
) {
Surface() {
Column(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.padding(16.dp)
) {
Text(name)
Spacer(modifier = Modifier.height(8.dp))
content()
Spacer(modifier = Modifier.height(8.dp))
Row {
Spacer(modifier = Modifier.weight(1f))
Button(onClick = {
onDismiss()
}) {
Text("Save")
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
package net.buzzert.kordophonedroid.ui.settings
import androidx.compose.runtime.MutableState
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import net.buzzert.kordophonedroid.ui.shared.ServerAuthentication
import net.buzzert.kordophonedroid.ui.shared.ServerConfigRepository
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
val serverConfigRepository: ServerConfigRepository
) : ViewModel() {
private val _serverPreference: MutableStateFlow<String> = MutableStateFlow("")
var serverPreference = _serverPreference.asStateFlow()
private val _usernamePreference: MutableStateFlow<String> = MutableStateFlow("")
var usernamePreference = _usernamePreference.asStateFlow()
private val _passwordPreference: MutableStateFlow<String> = MutableStateFlow("")
var passwordPreference = _passwordPreference.asStateFlow()
init {
val serverConfig = serverConfigRepository.serverConfig.value
serverConfig.serverName?.let { _serverPreference.value = it }
serverConfig.authentication?.let {
_usernamePreference.value = it.username
_passwordPreference.value = it.password
}
}
fun saveServerPreference(serverName: String) {
_serverPreference.value = serverName
serverConfigRepository.applyConfig {
this.serverName = serverName
}
}
fun saveAuthenticationPreferences(username: String, password: String) {
_usernamePreference.value = username
_passwordPreference.value = password
serverConfigRepository.applyConfig {
this.authentication = ServerAuthentication(username, password)
}
}
}

View File

@@ -0,0 +1,107 @@
package net.buzzert.kordophonedroid.ui.shared
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import net.buzzert.kordophone.backend.server.Authentication
import java.lang.reflect.Constructor
import javax.inject.Inject
import javax.inject.Singleton
data class ServerConfig(
var serverName: String? = null,
var authentication: ServerAuthentication? = null,
) {
companion object {
private const val SHARED_PREF_NAME = "KordophonePreferences"
fun loadFromSettings(context: Context): ServerConfig {
val prefs = getSharedPreferences(context)
return ServerConfig(
serverName = prefs.getString("serverName", null),
authentication = ServerAuthentication.loadFromEncryptedSettings(context)
)
}
private fun getSharedPreferences(context: Context): SharedPreferences {
return context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
}
}
fun saveToSettings(context: Context) {
val prefs = getSharedPreferences(context)
prefs.edit {
putString("serverName", serverName)
apply()
}
authentication?.saveToEncryptedSettings(context)
}
}
data class ServerAuthentication(
val username: String,
val password: String,
) {
companion object {
fun loadFromEncryptedSettings(context: Context): ServerAuthentication? {
val prefs = getEncryptedSharedPreferences(context)
val username = prefs.getString("username", null)
val password = prefs.getString("password", null)
if (username != null && password != null) {
return ServerAuthentication(username, password)
}
return null
}
private fun getEncryptedSharedPreferences(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences(
context,
"secrets",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
fun saveToEncryptedSettings(context: Context) {
val prefs = getEncryptedSharedPreferences(context)
prefs.edit {
putString("username", username)
putString("password", password)
apply()
}
}
fun toBackendAuthentication(): Authentication {
return Authentication(username, password)
}
}
@Singleton
class ServerConfigRepository @Inject constructor(
@ApplicationContext val context: Context,
) {
// TODO: Initial config should be loaded from device settings.
private val _serverConfig = MutableStateFlow(ServerConfig.loadFromSettings(context)) // Initial config
val serverConfig: StateFlow<ServerConfig> = _serverConfig
fun applyConfig(applicator: ServerConfig.() -> Unit) {
val config = _serverConfig.value.copy()
_serverConfig.value = config.apply(applicator)
_serverConfig.value.saveToSettings(context)
}
}

View File

@@ -0,0 +1,33 @@
package net.buzzert.kordophonedroid.ui.shared
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
const val LINK_ANNOTATION_TAG = "link"
private val LINK_REGEX = "(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]".toRegex()
fun String.linkify(): AnnotatedString {
val text = this
val matches = LINK_REGEX.findAll(this)
return buildAnnotatedString {
append(text)
for (match in matches) {
val range = match.range.also {
// Make inclusive.
IntRange(it.first, it.last + 1)
}
// Annotate link
addStringAnnotation(LINK_ANNOTATION_TAG, match.value, range.first, range.last)
// Add style
addStyle(
style = SpanStyle(textDecoration = TextDecoration.Underline),
start = range.first, end = range.last
)
}
}
}

View File

@@ -0,0 +1,8 @@
package net.buzzert.kordophonedroid.ui.theme
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)

View File

@@ -0,0 +1,11 @@
package net.buzzert.kordophonedroid.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)

View File

@@ -0,0 +1,83 @@
package net.buzzert.kordophonedroid.ui.theme
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.darkColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.style.TextOverflow
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
/* Other default colors to override
background = Color.White,
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
*/
)
@Composable
fun KordophoneTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
@Composable
fun KordophoneTopAppBar(title: String, backAction: () -> Unit, visible: Boolean = true) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { -it },
exit = slideOutVertically { -it },
) {
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = backAction) {
Icon(Icons.Filled.ArrowBack, null)
}
},
actions = {}
)
}
}

View File

@@ -0,0 +1,28 @@
package net.buzzert.kordophonedroid.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
)
/* Other default text styles to override
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.W500,
fontSize = 14.sp
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp
)
*/
)

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,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM480,520Q421,520 380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q533,800 580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800ZM480,440Q506,440 523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M720,630Q720,734 647,807Q574,880 470,880Q366,880 293,807Q220,734 220,630L220,260Q220,185 272.5,132.5Q325,80 400,80Q475,80 527.5,132.5Q580,185 580,260L580,610Q580,656 548,688Q516,720 470,720Q424,720 392,688Q360,656 360,610L360,240L440,240L440,610Q440,623 448.5,631.5Q457,640 470,640Q483,640 491.5,631.5Q500,623 500,610L500,260Q499,218 470.5,189Q442,160 400,160Q358,160 329,189Q300,218 300,260L300,630Q299,701 349,750.5Q399,800 470,800Q540,800 589,750.5Q638,701 640,630L640,240L720,240L720,630Z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:fillColor="@color/black"
>
<gradient
android:startColor="#000"
android:endColor="#333"
android:angle="1.0"
/>
</shape>

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M120,800L120,640L840,640L840,800L120,800ZM200,760L280,760L280,680L200,680L200,760ZM120,320L120,160L840,160L840,320L120,320ZM200,280L280,280L280,200L200,200L200,280ZM120,560L120,400L840,400L840,560L120,560ZM200,520L280,520L280,440L200,440L200,520Z"/>
</vector>

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="@mipmap/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="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

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,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#3D3D3D</color>
</resources>

View File

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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.KordophoneDroid" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
</style>
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">10.0.2.2</domain>
<domain includeSubdomains="true">tesseract.localdomain</domain>
<domain includeSubdomains="true">buzzert.kordophone.nor</domain>
</domain-config>
</network-security-config>

1
android/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,58 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'io.realm.kotlin'
}
android {
namespace 'net.buzzert.kordophone.backend'
compileSdk 33
defaultConfig {
minSdk 30
targetSdk 33
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
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 = JavaVersion.VERSION_1_8.toString()
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.9.0'
implementation 'androidx.core:core-ktx:1.10.1'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
// Third-party
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.google.code.gson:gson:2.9.0'
implementation 'com.auth0.android:jwtdecode:2.0.2'
// Realm
implementation "io.realm.kotlin:library-base:${realm_version}"
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
implementation group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: '1.7.3', ext: 'pom'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.1'
}

Binary file not shown.

Binary file not shown.

View File

21
android/backend/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 net.buzzert.kordophone.backend
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("net.buzzert.kordophone.backend.test", appContext.packageName)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,170 @@
package net.buzzert.kordophone.backend.db
import android.util.Log
import io.realm.kotlin.MutableRealm
import io.realm.kotlin.Realm
import io.realm.kotlin.RealmConfiguration
import io.realm.kotlin.UpdatePolicy
import io.realm.kotlin.ext.toRealmList
import io.realm.kotlin.query.find
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.map
import net.buzzert.kordophone.backend.db.model.Conversation
import net.buzzert.kordophone.backend.db.model.Message
import net.buzzert.kordophone.backend.db.model.toDatabaseConversation
import net.buzzert.kordophone.backend.db.model.toDatabaseMessage
import net.buzzert.kordophone.backend.db.model.toRealmInstant
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.server.REPO_LOG
import java.lang.IllegalArgumentException
import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
import net.buzzert.kordophone.backend.model.Message as ModelMessage
class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
companion object {
private val schema = setOf(Conversation::class, Message::class)
fun liveDatabase(): CachedChatDatabase {
return CachedChatDatabase(
RealmConfiguration.Builder(schema = schema)
.name("chat-cache")
.build()
)
}
fun testDatabase(): CachedChatDatabase {
return CachedChatDatabase(
RealmConfiguration.Builder(schema = schema)
.name("chat-cache-test")
.inMemory()
.build()
)
}
}
private val realm = runCatching {
Realm.open(realmConfig)
}.recover {
// We're just a caching layer, so in the event of a migration error, just delete and start over.
Log.d(REPO_LOG, "Error opening (${it.message}). Recovering by deleting database.")
Realm.deleteRealm(realmConfig)
return@recover Realm.open(realmConfig)
}.getOrThrow()
// Flow for watching changes to the database
val conversationChanges: Flow<List<ModelConversation>>
get() = realm.query(Conversation::class).find().asFlow().map {
realm.copyFromRealm(it.list)
.map { it.toConversation() }
}
// Flow for watching for message changes for a given conversation
fun messagesChanged(conversation: ModelConversation): Flow<List<ModelMessage>> {
return realm.query(Message::class, "conversationGUID == $0", conversation.guid)
.find()
.asFlow()
.map { it.list.map { it.toMessage(conversation) } }
}
fun updateConversations(incomingConversations: List<ModelConversation>) = realm.writeBlocking {
val incomingDatabaseConversations = incomingConversations.map { it.toDatabaseConversation() }
var deletedConversations = realm.query(Conversation::class).find()
.minus(incomingDatabaseConversations)
deletedConversations.forEach { conversation ->
findLatest(conversation)?.let {
delete(it)
}
}
writeManagedConversations(this, incomingDatabaseConversations)
}
fun writeConversations(conversations: List<ModelConversation>) = realm.writeBlocking {
writeManagedConversations(this, conversations.map { it.toDatabaseConversation() })
}
private fun writeManagedConversations(mutableRealm: MutableRealm, conversations: List<Conversation>) {
conversations.forEach {conversation ->
try {
val managedConversation = getManagedConversationByGuid(conversation.guid)
mutableRealm.findLatest(managedConversation)?.apply {
displayName = conversation.displayName
participants = conversation.participants
date = conversation.date
unreadCount = conversation.unreadCount
lastMessagePreview = conversation.lastMessagePreview
lastMessageGUID = conversation.lastMessageGUID
}
} catch (e: NoSuchElementException) {
// Conversation does not exist. Copy it to the realm.
mutableRealm.copyToRealm(conversation, updatePolicy = UpdatePolicy.ALL)
}
}
}
fun deleteConversations(conversations: List<ModelConversation>) = realm.writeBlocking {
conversations.forEach { inConversation ->
val conversation = getManagedConversationByGuid(inConversation.guid)
findLatest(conversation)?.let {
delete(it)
}
}
}
fun fetchConversations(): List<ModelConversation> {
val itemResults = realm.query(Conversation::class).find()
val items = realm.copyFromRealm(itemResults)
return items.map { it.toConversation() }
}
fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation, outgoing: Boolean = false) {
if (messages.isEmpty()) {
return
}
val dbConversation = getManagedConversationByGuid(conversation.guid)
realm.writeBlocking {
messages
.map { it.toDatabaseMessage(outgoing = outgoing) }
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
findLatest(dbConversation)?.let {
val lastMessage = messages.maxByOrNull { it.date }!!
val lastMessageDate = lastMessage.date.toInstant().toRealmInstant()
if (lastMessageDate > it.date) {
it.lastMessageGUID = lastMessage.guid
it.lastMessagePreview = lastMessage.displayText
// This will cause sort order to change. I think this ends
// up getting updated whenever we get conversation changes anyway.
// it.date = lastMessageDate
}
}
}
}
fun fetchMessages(conversation: ModelConversation): List<ModelMessage> {
return realm.query(Message::class, "conversationGUID == $0", conversation.guid)
.find()
.map { it.toMessage(conversation) }
}
fun close() {
realm.close()
}
private fun getManagedConversationByGuid(guid: GUID): Conversation {
return realm.query(Conversation::class, "guid == $0", guid)
.find()
.first()
}
fun getConversationByGuid(guid: GUID): Conversation {
return realm.copyFromRealm(getManagedConversationByGuid(guid))
}
}

View File

@@ -0,0 +1,75 @@
package net.buzzert.kordophone.backend.db.model
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.ext.toRealmList
import io.realm.kotlin.types.RealmInstant
import io.realm.kotlin.types.RealmList
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.annotations.PrimaryKey
import net.buzzert.kordophone.backend.model.GUID
import org.mongodb.kbson.ObjectId
import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
import java.util.Date
open class Conversation(
@PrimaryKey
var guid: GUID,
var displayName: String?,
var participants: RealmList<String>,
var date: RealmInstant,
var unreadCount: Int,
var lastMessageGUID: String?,
var lastMessagePreview: String?,
): RealmObject
{
constructor() : this(
guid = ObjectId().toString(),
displayName = null,
participants = realmListOf<String>(),
date = RealmInstant.now(),
unreadCount = 0,
lastMessagePreview = null,
lastMessageGUID = null,
)
fun toConversation(): ModelConversation {
return ModelConversation(
displayName = displayName,
participants = participants.toList(),
date = Date.from(date.toInstant()),
unreadCount = unreadCount,
guid = guid,
lastMessagePreview = lastMessagePreview,
lastMessage = null,
lastFetchedMessageGUID = lastMessageGUID
)
}
override fun equals(other: Any?): Boolean {
if (other == null || javaClass != other.javaClass) return false
val o = other as Conversation
return guid == o.guid
}
override fun hashCode(): Int {
return guid.hashCode()
}
}
fun ModelConversation.toDatabaseConversation(): Conversation {
val from = this
return Conversation().apply {
displayName = from.displayName
participants = from.participants.toRealmList()
date = from.date.toInstant().toRealmInstant()
unreadCount = from.unreadCount
lastMessagePreview = from.lastMessagePreview
lastMessageGUID = from.lastFetchedMessageGUID
guid = from.guid
}
}

View File

@@ -0,0 +1,37 @@
package net.buzzert.kordophone.backend.db.model
import io.realm.kotlin.types.RealmInstant
import java.time.Instant
// Copied from Realm's documentation
// https://www.mongodb.com/docs/realm/sdk/kotlin/realm-database/schemas/supported-types/
fun RealmInstant.toInstant(): Instant {
val sec: Long = this.epochSeconds
// The value always lies in the range `-999_999_999..999_999_999`.
// minus for timestamps before epoch, positive for after
val nano: Int = this.nanosecondsOfSecond
return if (sec >= 0) { // For positive timestamps, conversion can happen directly
Instant.ofEpochSecond(sec, nano.toLong())
} else {
// For negative timestamps, RealmInstant starts from the higher value with negative
// nanoseconds, while Instant starts from the lower value with positive nanoseconds
// TODO This probably breaks at edge cases like MIN/MAX
Instant.ofEpochSecond(sec - 1, 1_000_000 + nano.toLong())
}
}
fun Instant.toRealmInstant(): RealmInstant {
val sec: Long = this.epochSecond
// The value is always positive and lies in the range `0..999_999_999`.
val nano: Int = this.nano
return if (sec >= 0) { // For positive timestamps, conversion can happen directly
RealmInstant.from(sec, nano)
} else {
// For negative timestamps, RealmInstant starts from the higher value with negative
// nanoseconds, while Instant starts from the lower value with positive nanoseconds
// TODO This probably breaks at edge cases like MIN/MAX
RealmInstant.from(sec + 1, -1_000_000 + nano)
}
}

View File

@@ -0,0 +1,64 @@
package net.buzzert.kordophone.backend.db.model
import android.view.Display.Mode
import io.realm.kotlin.Realm
import io.realm.kotlin.ext.realmListOf
import io.realm.kotlin.ext.toRealmList
import io.realm.kotlin.types.EmbeddedRealmObject
import io.realm.kotlin.types.RealmInstant
import io.realm.kotlin.types.RealmList
import io.realm.kotlin.types.RealmObject
import io.realm.kotlin.types.annotations.PrimaryKey
import net.buzzert.kordophone.backend.db.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import org.mongodb.kbson.ObjectId
import net.buzzert.kordophone.backend.model.Message as ModelMessage
import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
import java.util.Date
open class Message(
@PrimaryKey
var guid: GUID,
var text: String,
var sender: String?,
var date: RealmInstant,
var attachmentGUIDs: RealmList<String>,
var conversationGUID: GUID,
): RealmObject
{
constructor() : this(
guid = ObjectId().toString(),
text = "",
sender = null,
date = RealmInstant.now(),
attachmentGUIDs = realmListOf<String>(),
conversationGUID = ObjectId().toString(),
)
fun toMessage(parentConversation: ModelConversation): ModelMessage {
return ModelMessage(
text = text,
guid = guid,
sender = sender,
date = Date.from(date.toInstant()),
attachmentGUIDs = attachmentGUIDs.toList(),
conversation = parentConversation,
)
}
}
fun ModelMessage.toDatabaseMessage(outgoing: Boolean = false): Message {
val from = this
return Message().apply {
text = from.text
guid = from.guid
sender = from.sender
date = from.date.toInstant().toRealmInstant()
conversationGUID = from.conversation.guid
from.attachmentGUIDs?.let {
attachmentGUIDs = it.toRealmList()
}
}
}

View File

@@ -0,0 +1,11 @@
package net.buzzert.kordophone.backend.events
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
data class MessageDeliveredEvent(
val message: Message,
val conversation: Conversation,
val requestGuid: GUID,
)

View File

@@ -0,0 +1,79 @@
package net.buzzert.kordophone.backend.model
import com.google.gson.annotations.SerializedName
import java.util.Date
import java.util.UUID
typealias GUID = String
data class Conversation(
@SerializedName("guid")
val guid: GUID,
@SerializedName("date")
var date: Date,
@SerializedName("participantDisplayNames")
var participants: List<String>,
@SerializedName("displayName")
var displayName: String?,
@SerializedName("unreadCount")
var unreadCount: Int,
@SerializedName("lastMessagePreview")
var lastMessagePreview: String?,
@SerializedName("lastMessage")
var lastMessage: Message?,
var lastFetchedMessageGUID: String?,
) {
companion object {
fun generate(): Conversation {
return Conversation(
guid = UUID.randomUUID().toString(),
date = Date(),
participants = listOf("foo@foo.com"),
displayName = null,
unreadCount = 0,
lastMessagePreview = null,
lastMessage = null,
lastFetchedMessageGUID = null,
)
}
}
val isGroupChat: Boolean
get() = participants.count() > 1
fun formattedDisplayName(): String {
return displayName ?: participants.joinToString(", ")
}
override fun equals(other: Any?): Boolean {
if (other == null || javaClass != other.javaClass) return false
val o = other as Conversation
return (
guid == o.guid &&
date == o.date &&
participants == o.participants &&
displayName == o.displayName &&
unreadCount == o.unreadCount &&
lastFetchedMessageGUID == o.lastFetchedMessageGUID
)
}
override fun hashCode(): Int {
var result = guid.hashCode()
result = 31 * result + date.hashCode()
result = 31 * result + participants.hashCode()
result = 31 * result + (displayName?.hashCode() ?: 0)
result = 31 * result + unreadCount
result = 31 * result + (lastMessage?.hashCode() ?: 0)
result = 31 * result + (lastFetchedMessageGUID?.hashCode() ?: 0)
return result
}
}

View File

@@ -0,0 +1,99 @@
package net.buzzert.kordophone.backend.model
import android.net.Uri
import com.google.gson.annotations.SerializedName
import java.io.InputStream
import java.util.Date
import java.util.UUID
data class Message(
@SerializedName("guid")
val guid: GUID,
@SerializedName("text")
val text: String,
@SerializedName("sender")
val sender: String?, // optional: nil means "from me"
@SerializedName("date")
val date: Date,
@SerializedName("fileTransferGUIDs")
val attachmentGUIDs: List<String>?,
@Transient
var conversation: Conversation,
) {
companion object {
fun generate(text: String, conversation: Conversation = Conversation.generate(), sender: String? = null): Message {
return Message(
guid = UUID.randomUUID().toString(),
text = text,
sender = sender,
date = Date(),
attachmentGUIDs = emptyList(),
conversation = conversation,
)
}
}
val displayText: String get() {
// Filter out attachment markers
val attachmentMarker = byteArrayOf(0xEF.toByte(), 0xBF.toByte(), 0xBC.toByte()).decodeToString()
return text.replace(attachmentMarker, "")
}
override fun toString(): String {
return "Message(guid=$guid, text=$text, sender=$sender, date=$date, parent=${conversation.guid})"
}
override fun equals(other: Any?): Boolean {
if (other == null || javaClass != other.javaClass) return false
val o = other as Message
return (
guid == o.guid &&
text == o.text &&
sender == o.sender &&
date == o.date &&
conversation.guid == o.conversation.guid
)
}
override fun hashCode(): Int {
var result = guid.hashCode()
result = 31 * result + text.hashCode()
result = 31 * result + (sender?.hashCode() ?: 0)
result = 31 * result + date.hashCode()
result = 31 * result + conversation.guid.hashCode()
return result
}
}
data class UploadingAttachmentMetadata(
val inputStream: InputStream,
val mimeType: String,
val filename: String,
)
data class OutgoingMessage(
val body: String,
val conversation: Conversation,
val attachmentUris: Set<Uri>,
val attachmentDataSource: (Uri) -> UploadingAttachmentMetadata?
) {
val guid: String = UUID.randomUUID().toString()
fun asMessage(): Message {
return Message(
guid = guid,
text = body,
sender = null,
date = Date(),
attachmentGUIDs = listOf(), // TODO: What to do here?
conversation = conversation
)
}
}

View File

@@ -0,0 +1,14 @@
package net.buzzert.kordophone.backend.model
import com.google.gson.annotations.SerializedName
data class UpdateItem(
@SerializedName("messageSequenceNumber")
val sequence: Int,
@SerializedName("conversation")
val conversationChanged: Conversation? = null,
@SerializedName("message")
val messageAdded: Message? = null,
)

View File

@@ -0,0 +1,218 @@
package net.buzzert.kordophone.backend.server
import kotlinx.coroutines.runBlocking
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import okhttp3.Authenticator
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.Route
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.URL
interface APIClient {
val isConfigured: Boolean
fun getAPIInterface(): APIInterface
fun getWebSocketClient(
serverPath: String,
queryParams: Map<String, String>?,
listener: WebSocketListener
): WebSocket
}
data class Authentication (
val username: String,
val password: String,
)
class TokenStore(val authentication: Authentication) {
var authenticationToken: String? = null
}
class AuthenticationInterceptor(
val tokenStore: TokenStore
): Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// If empty, allow the 401 to occur so we renew our token.
val token = tokenStore.authenticationToken ?:
return chain.proceed(chain.request())
val newRequest = chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(newRequest)
}
}
class TokenAuthenticator(
private val tokenStore: TokenStore,
private val baseURL: URL
) : Authenticator {
private val retrofit: Retrofit = Retrofit.Builder()
.baseUrl(baseURL)
.addConverterFactory(GsonConverterFactory.create())
.build()
private val apiInterface: APIInterface
get() = retrofit.create(APIInterface::class.java)
override fun authenticate(route: Route?, response: Response): Request? {
// Fetch new token
val request = AuthenticationRequest(
username = tokenStore.authentication.username,
password = tokenStore.authentication.password
)
val token = runBlocking {
apiInterface.authenticate(request).body()
}
when (token) {
null -> {
// Auth failure.
// TODO: How to bubble this up?
return null
}
// Update token store
else -> {
tokenStore.authenticationToken = token.serializedToken
return response.request().newBuilder()
.header("Authorization", "Bearer ${token.serializedToken}")
.build()
}
}
}
}
class APIClientFactory {
companion object {
fun createClient(serverString: String?, authentication: Authentication?): APIClient {
if (serverString == null || authentication == null) {
return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.NOT_CONFIGURED)
}
// Try to parse server string
val serverURL = HttpUrl.parse(serverString)
?: return InvalidConfigurationAPIClient(InvalidConfigurationAPIClient.Issue.INVALID_HOST_URL)
return RetrofitAPIClient(serverURL.url(), authentication)
}
}
}
// TODO: Is this a dumb idea?
class InvalidConfigurationAPIClient(val issue: Issue): APIClient {
enum class Issue {
NOT_CONFIGURED,
INVALID_CONFIGURATION,
INVALID_HOST_URL,
}
class NotConfiguredError: Throwable(message = "Not configured.")
class InvalidConfigurationError(submessage: String): Throwable(message = "Invalid configuration: $submessage")
private class InvalidConfigurationAPIInterface(val issue: Issue): APIInterface {
private fun throwError(): Nothing {
when (issue) {
Issue.NOT_CONFIGURED -> throw NotConfiguredError()
Issue.INVALID_CONFIGURATION -> throw InvalidConfigurationError("Unknown.")
Issue.INVALID_HOST_URL -> throw InvalidConfigurationError("Invalid host URL.")
}
}
override suspend fun getVersion(): ResponseBody = throwError()
override suspend fun getConversations(): retrofit2.Response<List<Conversation>> = throwError()
override suspend fun sendMessage(request: SendMessageRequest): retrofit2.Response<SendMessageResponse> = throwError()
override suspend fun markConversation(conversationGUID: String): retrofit2.Response<Void> = throwError()
override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody = throwError()
override suspend fun uploadAttachment(filename: String, body: RequestBody): retrofit2.Response<UploadAttachmentResponse> = throwError()
override suspend fun authenticate(request: AuthenticationRequest): retrofit2.Response<AuthenticationResponse> = throwError()
override suspend fun getMessages(conversationGUID: String, limit: Int?, beforeMessageGUID: GUID?, afterMessageGUID: GUID?): retrofit2.Response<List<Message>> = throwError()
}
override val isConfigured: Boolean
get() { return issue != Issue.NOT_CONFIGURED }
override fun getAPIInterface(): APIInterface {
return InvalidConfigurationAPIInterface(issue)
}
override fun getWebSocketClient(
serverPath: String,
queryParams: Map<String, String>?,
listener: WebSocketListener
): WebSocket {
throw Error("Invalid configuration.")
}
}
class RetrofitAPIClient(
private val baseURL: URL,
private val authentication: Authentication,
): APIClient {
private val tokenStore: TokenStore = TokenStore(authentication)
private val client: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthenticationInterceptor(tokenStore))
.authenticator(TokenAuthenticator(tokenStore, baseURL))
.build()
private val retrofit: Retrofit = Retrofit.Builder()
.client(client)
.baseUrl(baseURL)
.addConverterFactory(GsonConverterFactory.create())
.build()
override val isConfigured: Boolean
get() = true
override fun getAPIInterface(): APIInterface {
return retrofit.create(APIInterface::class.java)
}
override fun getWebSocketClient(
serverPath: String,
queryParams: Map<String, String>?,
listener: WebSocketListener
): WebSocket {
val params = (queryParams ?: hashMapOf<String, String>()).toMutableMap()
val authToken = tokenStore.authenticationToken
if (authToken != null) {
params["token"] = authToken
}
val requestURL = baseURL.authenticatedWebSocketURL(serverPath, params)
val request = Request.Builder()
.url(requestURL)
.build()
return client.newWebSocket(request, listener)
}
}
fun URL.authenticatedWebSocketURL(serverPath: String, params: Map<String, String>): URL {
val baseURI = HttpUrl.parse(this.toString())!!
val requestURL = baseURI.newBuilder()
.host(baseURI.host())
.addEncodedPathSegments(serverPath)
params.forEach { (key, value) ->
requestURL.addQueryParameter(key, value)
}
return URL(requestURL.build().toString())
}

View File

@@ -0,0 +1,96 @@
package net.buzzert.kordophone.backend.server
import com.auth0.android.jwt.JWT
import com.google.gson.annotations.SerializedName
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import java.lang.Error
import java.lang.Exception
data class SendMessageRequest(
@SerializedName("guid")
val conversationGUID: String,
@SerializedName("body")
val body: String,
@SerializedName("fileTransferGUIDs")
val transferGUIDs: List<String>?,
)
data class SendMessageResponse(
@SerializedName("guid")
val sentMessageGUID: String,
)
data class AuthenticationRequest(
@SerializedName("username")
val username: String,
@SerializedName("password")
val password: String,
)
data class AuthenticationResponse(
@SerializedName("jwt")
val serializedToken: String,
) {
fun decodeToken(): JWT {
return JWT(serializedToken)
}
}
data class UploadAttachmentResponse(
@SerializedName("fileTransferGUID")
val transferGUID: String
)
interface APIInterface {
@GET("/version")
suspend fun getVersion(): ResponseBody
@GET("/conversations")
suspend fun getConversations(): Response<List<Conversation>>
@GET("/messages")
suspend fun getMessages(
@Query("guid") conversationGUID: String,
@Query("limit") limit: Int? = null,
@Query("beforeMessageGUID") beforeMessageGUID: GUID? = null,
@Query("afterMessageGUID") afterMessageGUID: GUID? = null,
): Response<List<Message>>
@POST("/sendMessage")
suspend fun sendMessage(@Body request: SendMessageRequest): Response<SendMessageResponse>
@POST("/markConversation")
suspend fun markConversation(@Query("guid") conversationGUID: String): Response<Void>
@GET("/attachment")
suspend fun fetchAttachment(@Query("guid") guid: String, @Query("preview") preview: Boolean = false): ResponseBody
@POST("/uploadAttachment")
suspend fun uploadAttachment(@Query("filename") filename: String, @Body body: RequestBody): Response<UploadAttachmentResponse>
@POST("/authenticate")
suspend fun authenticate(@Body request: AuthenticationRequest): Response<AuthenticationResponse>
}
class ResponseDecodeError(val response: ResponseBody): Exception(response.string())
fun <T> Response<T>.bodyOnSuccessOrThrow(): T {
if (isSuccessful) {
return body()!!
}
throw ResponseDecodeError(errorBody()!!)
}

View File

@@ -0,0 +1,333 @@
package net.buzzert.kordophone.backend.server
import android.net.Uri
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.db.CachedChatDatabase
import net.buzzert.kordophone.backend.events.MessageDeliveredEvent
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.OutgoingMessage
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.BufferedSource
import java.io.InputStream
import java.util.Date
import java.util.UUID
import java.util.concurrent.ArrayBlockingQueue
const val REPO_LOG: String = "ChatRepository"
const val CONVERSATION_MESSAGE_SYNC_COUNT = 10
class ChatRepository(
private var apiClient: APIClient,
private val database: CachedChatDatabase,
) {
sealed class Error {
open val title: String = "Error"
open val description: String = "Generic Error"
data class ConnectionError(val message: String?): Error() {
override val title: String = "Connection Error"
override val description: String = message ?: "???"
}
data class AttachmentError(val message: String): Error() {
override val title: String = "Attachment Error"
override val description: String = message
}
}
// All (Cached) Conversations
val conversations: List<Conversation>
get() = database.fetchConversations()
// Channel that's signaled when an outgoing message is delivered.
val messageDeliveredChannel: SharedFlow<MessageDeliveredEvent>
get() = _messageDeliveredChannel.asSharedFlow()
// Changes Flow
val conversationChanges: Flow<List<Conversation>>
get() = database.conversationChanges
.onEach { Log.d(REPO_LOG, "Got database conversations changed") }
// New Messages
val newMessages: SharedFlow<Message>
get() = _newMessageChannel.asSharedFlow()
// Errors channel
val errorEncounteredChannel: SharedFlow<Error>
get() = _errorEncounteredChannel.asSharedFlow()
val isConfigured: Boolean
get() = apiClient.isConfigured
// New messages for a particular conversation
fun messagesChanged(conversation: Conversation): Flow<List<Message>> =
database.messagesChanged(conversation)
// Testing harness
internal class TestingHarness(private val repository: ChatRepository) {
suspend fun fetchConversations(): List<Conversation> {
return repository.fetchConversations()
}
suspend fun fetchMessages(conversation: Conversation): List<Message> {
return repository.fetchMessages(conversation)
}
}
internal fun testingHarness(): TestingHarness = TestingHarness(this)
private var apiInterface = apiClient.getAPIInterface()
private val outgoingMessageQueue: ArrayBlockingQueue<OutgoingMessage> = ArrayBlockingQueue(16)
private var outgoingMessageThread: Thread? = null
private val _messageDeliveredChannel = MutableSharedFlow<MessageDeliveredEvent>()
private val _errorEncounteredChannel = MutableSharedFlow<Error>()
private val _newMessageChannel = MutableSharedFlow<Message>()
private var updateMonitor = UpdateMonitor(apiClient)
private var updateWatchJob: Job? = null
private var updateWatchScope: CoroutineScope? = null
fun updateAPIClient(client: APIClient) {
this.apiClient = client
this.apiInterface = client.getAPIInterface()
this.updateMonitor = UpdateMonitor(client)
// Restart update watch job, if necessary.
if (this.updateWatchJob != null) {
stopWatchingForUpdates()
beginWatchingForUpdates(updateWatchScope!!)
}
}
suspend fun getVersion(): String {
return apiInterface.getVersion().string()
}
fun beginWatchingForUpdates(scope: CoroutineScope) {
updateWatchJob?.cancel()
updateWatchJob = CoroutineScope(scope.coroutineContext).launch {
launch {
updateMonitor.conversationChanged.collect { handleConversationChangedUpdate(it) }
}
launch {
updateMonitor.messageAdded.collect { handleMessageAddedUpdate(it) }
}
launch {
messageDeliveredChannel.collectLatest { handleMessageDelivered(it) }
}
}
updateWatchScope = scope
updateMonitor.beginMonitoringUpdates()
}
fun stopWatchingForUpdates() {
updateWatchJob?.cancel()
updateWatchJob = null
updateMonitor.stopMonitoringForUpdates()
}
fun enqueueOutgoingMessage(message: OutgoingMessage): GUID {
val guid = UUID.randomUUID().toString()
Log.d(REPO_LOG, "Enqueuing outgoing message: $message ($guid)")
outgoingMessageQueue.add(message)
if (outgoingMessageThread == null) {
outgoingMessageThread = Thread { outgoingMessageQueueMain() }
outgoingMessageThread?.start()
}
return guid
}
fun conversationForGuid(guid: GUID): Conversation {
return database.getConversationByGuid(guid).toConversation()
}
fun messagesForConversation(conversation: Conversation): List<Message> {
return database.fetchMessages(conversation)
}
suspend fun synchronize() = withErrorChannelHandling {
Log.d(REPO_LOG, "Synchronizing conversations")
// Sync conversations
val serverConversations = fetchConversations()
database.updateConversations(serverConversations)
// Sync top N number of conversations' message content
Log.d(REPO_LOG, "Synchronizing messages")
val sortedConversations = conversations.sortedBy { it.date }.reversed()
for (conversation in sortedConversations.take(CONVERSATION_MESSAGE_SYNC_COUNT)) {
synchronizeConversation(conversation)
}
}
suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = withErrorChannelHandling {
val messages = fetchMessages(conversation, limit = limit, afterGUID = conversation.lastFetchedMessageGUID)
database.writeMessages(messages, conversation)
}
suspend fun markConversationAsRead(conversation: Conversation) = withErrorChannelHandling(silent = true) {
apiInterface.markConversation(conversation.guid)
}
suspend fun fetchAttachmentDataSource(guid: String, preview: Boolean): BufferedSource {
return apiInterface.fetchAttachment(guid, preview).source()
}
private suspend fun uploadAttachment(filename: String, mediaType: String, source: InputStream): String {
val attachmentData = source.readBytes()
val requestBody = RequestBody.create(MediaType.get(mediaType), attachmentData)
withContext(Dispatchers.IO) {
source.close()
}
val response = apiInterface.uploadAttachment(filename, requestBody)
return response.bodyOnSuccessOrThrow().transferGUID
}
fun close() {
database.close()
}
// - private
private suspend fun withErrorChannelHandling(silent: Boolean = false, body: suspend () -> Unit) {
try {
body()
} catch (e: InvalidConfigurationAPIClient.NotConfiguredError) {
// Not configured yet: ignore.
} catch (e: java.lang.Exception) {
if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
} catch (e: java.lang.Error) {
if (!silent) _errorEncounteredChannel.emit(Error.ConnectionError(e.message))
}
}
private suspend fun fetchConversations(): List<Conversation> {
return apiInterface.getConversations().bodyOnSuccessOrThrow()
}
private suspend fun fetchMessages(
conversation: Conversation,
limit: Int? = null,
beforeGUID: String? = null,
afterGUID: String? = null,
): List<Message> {
return apiInterface.getMessages(conversation.guid, limit, beforeGUID, afterGUID)
.bodyOnSuccessOrThrow()
.onEach { it.conversation = conversation }
}
private fun handleConversationChangedUpdate(conversation: Conversation) {
Log.d(REPO_LOG, "Handling conversation changed update")
database.writeConversations(listOf(conversation))
}
private suspend fun handleMessageAddedUpdate(message: Message) {
Log.d(REPO_LOG, "Handling messages added update")
database.writeMessages(listOf(message), message.conversation)
_newMessageChannel.emit(message)
}
private suspend fun handleMessageDelivered(event: MessageDeliveredEvent) {
Log.d(REPO_LOG, "Handling successful delivery event")
// Unfortunate protocol reality: the server doesn't tell us about new messages that are from us,
// so we have to explicitly handle this like a messageAddedUpdate.
database.writeMessages(listOf(event.message), event.conversation, outgoing = true)
}
private suspend fun retryMessageSend(info: OutgoingMessage) {
delay(5000L)
outgoingMessageQueue.add(info)
}
private fun outgoingMessageQueueMain() {
Log.d(REPO_LOG, "Outgoing Message Queue Main")
while (true) {
outgoingMessageQueue.take().let {
runBlocking {
val conversation = it.conversation
val requestGuid = it.guid
val body = it.body
Log.d(REPO_LOG, "Sending message to $conversation: $requestGuid")
// Upload attachments first
val attachmentGUIDs = mutableListOf<String>()
try {
for (uri: Uri in it.attachmentUris) {
val uploadData = it.attachmentDataSource(uri)
?: throw java.lang.Exception("No upload data.")
val guid = uploadAttachment(uploadData.filename, uploadData.mimeType, uploadData.inputStream)
attachmentGUIDs.add(guid)
}
} catch (e: java.lang.Exception) {
Log.e(REPO_LOG, "Error uploading attachment (${e.message}). Dropping...")
_errorEncounteredChannel.emit(Error.AttachmentError("Upload error: ${e.message}"))
}
try {
val result = apiInterface.sendMessage(
SendMessageRequest(
conversationGUID = conversation.guid,
body = body,
transferGUIDs = attachmentGUIDs,
)
)
if (result.isSuccessful) {
val messageGuid = result.body()?.sentMessageGUID ?: requestGuid
Log.d(REPO_LOG, "Successfully sent message: $messageGuid")
val newMessage = Message(
guid = messageGuid,
text = body,
sender = null,
conversation = it.conversation,
date = Date(),
attachmentGUIDs = attachmentGUIDs,
)
_messageDeliveredChannel.emit(
MessageDeliveredEvent(
newMessage,
conversation,
requestGuid
)
)
} else {
Log.e(REPO_LOG, "Error sending message. Enqueuing for retry.")
retryMessageSend(it)
}
} catch (e: java.lang.Exception) {
Log.e(REPO_LOG, "Error sending message: (${e.message}). Enqueuing for retry in 5 sec.")
retryMessageSend(it)
}
}
}
}
}
}

View File

@@ -0,0 +1,133 @@
package net.buzzert.kordophone.backend.server
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.UpdateItem
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.lang.reflect.Type
const val UPMON_LOG: String = "UpdateMonitor"
class UpdateMonitor(private val client: APIClient) : WebSocketListener() {
// Flow for getting conversation changed notifications
val conversationChanged: Flow<Conversation>
get() = _conversationChanged
// Flow for messages added notifications
val messageAdded: Flow<Message>
get() = _messageAdded
private val gson: Gson = Gson()
private val updateItemsType: Type = object : TypeToken<ArrayList<UpdateItem>>() {}.type
private var webSocket: WebSocket? = null
private var needsSocketReconnect: Boolean = false
private var messageSeq: Int = -1
private val _conversationChanged: MutableSharedFlow<Conversation> = MutableSharedFlow()
private val _messageAdded: MutableSharedFlow<Message> = MutableSharedFlow()
fun beginMonitoringUpdates() {
if (!client.isConfigured) {
Log.e(UPMON_LOG, "Closing websocket connection because client is not configured.")
return
}
Log.d(UPMON_LOG, "Opening websocket connection")
try {
this.webSocket = client.getWebSocketClient(
serverPath = "updates",
queryParams = mapOf("seq" to messageSeq.toString()),
listener = this
)
} catch (e: Error) {
Log.e(UPMON_LOG, "Error getting websocket client: ${e.message}")
}
}
fun stopMonitoringForUpdates() {
this.webSocket?.close(1000, "Closing on program request.")
}
private fun processEncodedSocketMessage(message: String) = runBlocking {
val reader = message.reader()
val jsonReader = gson.newJsonReader(reader)
val updateItems: List<UpdateItem> = gson.fromJson(message, updateItemsType)
for (updateItem: UpdateItem in updateItems) {
val conversationChanged = updateItem.conversationChanged
if (conversationChanged != null) {
_conversationChanged.emit(conversationChanged)
}
if (updateItem.messageAdded != null) {
_messageAdded.emit(updateItem.messageAdded.also {
it.conversation = conversationChanged!!
})
}
if (updateItem.sequence > messageSeq) {
messageSeq = updateItem.sequence
}
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun setNeedsSocketReconnect() {
if (!needsSocketReconnect) {
needsSocketReconnect = true
GlobalScope.launch {
needsSocketReconnect = false
// Delay 5 seconds
delay(5000L)
beginMonitoringUpdates()
}
}
}
// <WebSocketListener>
override fun onOpen(webSocket: WebSocket, response: Response) {
super.onOpen(webSocket, response)
Log.d(UPMON_LOG, "Update monitor websocket open")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason)
Log.d(UPMON_LOG, "Update monitor socket closed")
setNeedsSocketReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
super.onFailure(webSocket, t, response)
Log.d(UPMON_LOG, "Update monitor socket failure: ${t.message} :: Response: ${response?.body()}. Reconnecting in 5 seconds.")
setNeedsSocketReconnect()
}
override fun onMessage(webSocket: WebSocket, text: String) {
super.onMessage(webSocket, text)
Log.d(UPMON_LOG, "Update monitor websocket received text message")
processEncodedSocketMessage(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
super.onMessage(webSocket, bytes)
Log.d(UPMON_LOG, "Update monitor websocket received bytes message")
processEncodedSocketMessage(bytes.utf8())
}
}

View File

@@ -0,0 +1,345 @@
package net.buzzert.kordophone.backend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.buzzert.kordophone.backend.db.CachedChatDatabase
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.OutgoingMessage
import net.buzzert.kordophone.backend.server.APIClient
import net.buzzert.kordophone.backend.server.APIInterface
import net.buzzert.kordophone.backend.server.Authentication
import net.buzzert.kordophone.backend.server.ChatRepository
import net.buzzert.kordophone.backend.server.RetrofitAPIClient
import net.buzzert.kordophone.backend.server.UpdateMonitor
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.URL
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class BackendTests {
private fun liveRepository(host: String): Pair<ChatRepository, RetrofitAPIClient> {
val client = RetrofitAPIClient(URL(host), authentication = Authentication("test", "test"))
val database = CachedChatDatabase.testDatabase()
val repository = ChatRepository(client, database)
return Pair(repository, client)
}
private fun mockRepository(): Pair<ChatRepository, MockServer> {
val mockServer = MockServer()
val database = CachedChatDatabase.testDatabase()
val repository = ChatRepository(mockServer.getClient(), database)
return Pair(repository, mockServer)
}
@Test
fun testGetVersion() = runBlocking {
val (repository, mockServer) = mockRepository()
val version = repository.getVersion()
assertEquals(version, mockServer.version)
repository.close()
}
@Test
fun testFetchConversations() = runBlocking {
val (repository, mockServer) = mockRepository()
// Add conversation to mock server
val inConversation = mockServer.addTestConversations(1).first()
val conversations = repository.testingHarness().fetchConversations()
assertEquals(conversations.count(), 1)
val outConversation = conversations.first()
assertEquals(inConversation, outConversation)
repository.close()
}
@Test
fun testFetchMessages() = runBlocking {
val (repository, mockServer) = mockRepository()
// Add conversation & message to mock server
val inConversation = mockServer.addTestConversations(1).first()
val inMessage = mockServer.addTestMessages(1, inConversation).first()
val conversations = repository.testingHarness().fetchConversations()
val messages = repository.testingHarness().fetchMessages(conversations.first())
assertEquals(messages.count(), 1)
val outMessage = messages.first()
assertEquals(outMessage, inMessage)
repository.close()
}
@Test
fun testSendMessage() = runBlocking {
val (repository, mockServer) = mockRepository()
val conversation = mockServer.addTestConversations(1).first()
val generatedMessage = MockServer.generateMessage(conversation)
val outgoingMessage = OutgoingMessage(
body = generatedMessage.text,
conversation = conversation,
attachmentUris = setOf(),
attachmentDataSource = { null },
)
repository.enqueueOutgoingMessage(outgoingMessage)
val event = repository.messageDeliveredChannel.first()
assertEquals(event.message.text, outgoingMessage.body)
repository.close()
}
@Test
fun testConversationSynchronization() = runBlocking {
val (repo, mockServer) = mockRepository()
// Add some test convos
val conversations = mockServer.addTestConversations(10)
// Sync
repo.synchronize()
// Check our count.
assertEquals(10, repo.conversations.count())
// Sync again: let's ensure we're de-duplicating conversations.
repo.synchronize()
// Should be no change...
assertEquals(10, repo.conversations.count())
// Say unread count + lastMessage preview changes on server.
val someConversation = conversations.first().apply {
displayName = "COOL"
unreadCount = 2
}
// Sync again
repo.synchronize()
// Make sure change is reflected
val readConversation = repo.conversationForGuid(someConversation.guid)
assertEquals("COOL", readConversation.displayName)
assertEquals(2, readConversation.unreadCount)
repo.close()
}
@Test
fun testConversationFlowUpdates() = runBlocking {
val (repo, mockServer) = mockRepository()
// Set up flow watcher, asynchronously
val updateLatch = CountDownLatch(1)
val job = launch {
println("Watching for conversations changes...")
repo.conversationChanges.collect {
println("Changed conversations: $it")
// We got it.
if (it.isNotEmpty()) {
println("bink")
updateLatch.countDown()
cancel()
}
}
}
withContext(Dispatchers.IO) {
// Add a conversation
println("Adding conversation")
mockServer.addTestConversations(1)
// Sync. This should trigger an update
println("Synchronizing...")
repo.synchronize()
// Wait for the coroutine that is collecting the flow to finish
job.join()
// Ensure the updates have been processed before proceeding
assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
}
}
@Test
fun testMessageFlowUpdates() = runBlocking {
val (repo, mockServer) = mockRepository()
// Add an existing conversation
println("Adding conversation")
val conversation = mockServer.addTestConversations(1).first()
// Initial sync
println("Initial sync")
repo.synchronize()
// Set up flow watcher, asynchronously
var messagesAdded: List<Message>? = null
val updateLatch = CountDownLatch(1)
val job = launch {
println("Watching for messages to be added...")
repo.messagesChanged(conversation).collect {
println("Messages changed: $it")
if (it.isNotEmpty()) {
messagesAdded = it
updateLatch.countDown()
cancel()
}
}
}
withContext(Dispatchers.IO) {
// Add a message
val messages = mockServer.addTestMessages(10, conversation)
// Sync. This should trigger an update
println("Synchronizing...")
repo.synchronize()
// Wait for the coroutine that is collecting the flow to finish
job.join()
// Ensure the updates have been processed before proceeding
assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
// Check what we got back
assertEquals(messages, messagesAdded)
}
}
@Test
fun testUpdateMonitorForConversations() = runBlocking {
val mockServer = MockServer()
val mockAPIClient = mockServer.getClient()
val updateMonitor = UpdateMonitor(mockAPIClient)
// Set up flow watcher, asynchronously
val updateLatch = CountDownLatch(1)
val job = launch {
updateMonitor.beginMonitoringUpdates()
updateMonitor.conversationChanged.collect {
println("Got conversation changed: $it")
updateLatch.countDown()
updateMonitor.stopMonitoringForUpdates()
mockAPIClient.stopWatchingForUpdates()
cancel()
}
}
withContext(Dispatchers.IO) {
Thread.sleep(500)
// Add a conversation
println("Adding conversation")
mockServer.addTestConversations(1)
// Wait for the coroutine that is collecting the flow to finish
job.join()
// Ensure the updates have been processed before proceeding
assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
}
}
@Test
fun testUpdateMonitorForMessages() = runBlocking {
val mockServer = MockServer()
val mockAPIClient = mockServer.getClient()
val updateMonitor = UpdateMonitor(mockAPIClient)
// Set up flow watcher, asynchronously
val updateLatch = CountDownLatch(1)
val job = launch {
updateMonitor.beginMonitoringUpdates()
updateMonitor.messageAdded.collect {
println("Got message added: $it")
updateLatch.countDown()
updateMonitor.stopMonitoringForUpdates()
mockAPIClient.stopWatchingForUpdates()
cancel()
}
}
withContext(Dispatchers.IO) {
Thread.sleep(500)
// Add a conversation
println("Adding conversation")
val convo = mockServer.addTestConversations(1).first()
// Add a test message
mockServer.addTestMessages(1, convo)
// Wait for the coroutine that is collecting the flow to finish
job.join()
// Ensure the updates have been processed before proceeding
assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
}
}
@Test
fun testEndToEndMessageUpdates() = runBlocking {
val (repo, mockServer) = mockRepository()
val conversation = mockServer.addTestConversations(1).first()
// Initial sync
repo.synchronize()
// We're going to generate a couple of messages...
val messagesToGenerate = 5
// Start watching for N updates
val updateLatch = CountDownLatch(messagesToGenerate)
val monitorJob = launch {
repo.messagesChanged(conversation).collect {
println("Message changed: $it")
if (it.isNotEmpty()) {
updateLatch.countDown()
}
if (updateLatch.count == 0L) {
repo.stopWatchingForUpdates()
cancel()
}
}
}
withContext(Dispatchers.IO) {
repo.beginWatchingForUpdates(this)
Thread.sleep(500)
// Should trigger an update
println("Adding messages")
mockServer.addTestMessages(messagesToGenerate, conversation)
monitorJob.join()
assertTrue(updateLatch.await(1, TimeUnit.SECONDS))
// Check num messages
val allMessages = repo.messagesForConversation(conversation)
assertEquals(messagesToGenerate, allMessages.count())
}
}
}

View File

@@ -0,0 +1,78 @@
package net.buzzert.kordophone.backend
import net.buzzert.kordophone.backend.db.CachedChatDatabase
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.Message
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Test
import java.util.Date
class DatabaseTests {
@Test
fun testConversationRetrieval() {
val db = CachedChatDatabase.testDatabase()
val conversation = MockServer.generateConversation()
db.writeConversations(listOf(conversation))
val readBackConversations = db.fetchConversations()
assertEquals(readBackConversations.count(), 1)
val readConversation = readBackConversations[0]
assertEquals(readConversation, conversation)
db.close()
}
@Test
fun testMessageRetrieval() {
val db = CachedChatDatabase.testDatabase()
val conversation = MockServer.generateConversation()
db.writeConversations(listOf(conversation))
var messages = listOf(
MockServer.generateMessage(conversation),
MockServer.generateMessage(conversation),
)
db.writeMessages(messages, conversation)
val readMessages = db.fetchMessages(conversation)
assertEquals(readMessages, messages)
assertEquals(readMessages[0].conversation.guid, conversation.guid)
db.close()
}
@Test
fun testConversationModification() {
val db = CachedChatDatabase.testDatabase()
var conversation = MockServer.generateConversation().apply {
displayName = "HooBoy"
}
db.writeConversations(listOf(conversation))
val readConversation = db.fetchConversations().first()
assertEquals(conversation.displayName, "HooBoy")
// Change display name
conversation.displayName = "wow"
// Write back
db.writeConversations(listOf(conversation))
val nowConversations = db.fetchConversations()
// Make sure we didn't duplicate
assertEquals(nowConversations.count(), 1)
// Make sure our new name was written
assertEquals(nowConversations.first().displayName, "wow")
db.close()
}
}

View File

@@ -0,0 +1,292 @@
package net.buzzert.kordophone.backend
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import net.buzzert.kordophone.backend.model.Conversation
import net.buzzert.kordophone.backend.model.GUID
import net.buzzert.kordophone.backend.model.Message
import net.buzzert.kordophone.backend.model.UpdateItem
import net.buzzert.kordophone.backend.server.APIClient
import net.buzzert.kordophone.backend.server.APIInterface
import net.buzzert.kordophone.backend.server.AuthenticationRequest
import net.buzzert.kordophone.backend.server.AuthenticationResponse
import net.buzzert.kordophone.backend.server.SendMessageRequest
import net.buzzert.kordophone.backend.server.SendMessageResponse
import net.buzzert.kordophone.backend.server.UploadAttachmentResponse
import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import retrofit2.Response
import java.util.Date
import java.util.UUID
@OptIn(ExperimentalStdlibApi::class)
class MockServer {
val version = "Kordophone-2.0"
val conversations: MutableList<Conversation> = mutableListOf()
val updateFlow: Flow<UpdateItem> get() = _updateFlow
var updateMessageSequence: Int = 0
private val messages: MutableMap<String, MutableList<Message>> = mutableMapOf()
private val _updateFlow: MutableSharedFlow<UpdateItem> = MutableSharedFlow()
private val client = MockServerClient(this)
companion object {
fun generateMessage(parentConversation: Conversation): Message {
return Message(
date = Date(),
text = "This is a test!",
guid = UUID.randomUUID().toString(),
sender = null,
conversation = parentConversation,
attachmentGUIDs = null,
)
}
fun generateConversation(): Conversation {
return Conversation(
date = Date(),
participants = listOf("james@magahern.com"),
displayName = null,
unreadCount = 0,
lastMessagePreview = null,
lastMessage = null,
guid = UUID.randomUUID().toString(),
lastFetchedMessageGUID = null,
)
}
}
fun getServer(): MockWebServer = MockWebServer()
fun getClient(): MockServerClient = client
fun getAPIInterface(): APIInterface = MockServerClient(this).getAPIInterface()
fun addConversation(conversation: Conversation) {
conversations.add(conversation)
messages[conversation.guid] = mutableListOf()
runBlocking {
_updateFlow.emit(UpdateItem(
sequence = updateMessageSequence++,
conversationChanged = conversation
))
}
}
fun updateConversation(conversation: Conversation) {
conversations.removeAll { it.guid == conversation.guid }
addConversation(conversation)
}
fun addMessagesToConversation(conversation: Conversation, messages: List<Message>) {
val guid = conversation.guid
this.messages[guid]?.addAll(messages)
conversation.lastMessage = messages.last()
conversation.lastMessagePreview = messages.last().text
runBlocking {
for (message in messages) {
_updateFlow.emit(
UpdateItem(
sequence = updateMessageSequence++,
conversationChanged = conversation,
messageAdded = message
)
)
}
}
}
fun addTestConversations(count: Int): List<Conversation> {
val testConversations = ArrayList<Conversation>()
for (i in 0..<count) {
val conversation = MockServer.generateConversation()
testConversations.add(conversation)
addConversation(conversation)
}
return testConversations
}
fun addTestMessages(count: Int, conversation: Conversation): List<Message> {
val testMessages = ArrayList<Message>()
for (i in 0..<count) {
val message = MockServer.generateMessage(conversation)
testMessages.add(message)
}
addMessagesToConversation(conversation, testMessages)
return testMessages
}
fun markConversationAsRead(guid: GUID) {
val conversation = conversations.first { it.guid == guid }
conversation.unreadCount = 0
updateConversation(conversation)
}
internal fun getMessagesForConversationGUID(guid: GUID): List<Message>? {
return messages[guid]?.toList()
}
internal fun sendMessage(body: String, toConversationGUID: GUID): Message {
val conversation = conversations.first { it.guid == toConversationGUID }
val message = Message(
text = body,
date = Date(),
guid = UUID.randomUUID().toString(),
sender = null, // me
conversation = conversation,
attachmentGUIDs = null,
)
addMessagesToConversation(conversation, listOf(message))
return message
}
}
class MockServerClient(private val server: MockServer): APIClient, WebSocketListener() {
private var updateWebSocket: WebSocket? = null
private var updateWatchJob: Job? = null
private val gson: Gson = Gson()
override val isConfigured: Boolean = true
override fun getAPIInterface(): APIInterface {
return MockServerInterface(server)
}
override fun getWebSocketClient(
serverPath: String,
queryParams: Map<String, String>?,
listener: WebSocketListener
): WebSocket {
val webServer = server.getServer()
val params = queryParams ?: mapOf()
val baseHTTPURL: HttpUrl = webServer.url("/")
val baseURL = baseHTTPURL.toUrl()
val requestURL = baseURL.authenticatedWebSocketURL(serverPath, params)
val request = Request.Builder()
.url(requestURL)
.build()
webServer.enqueue(MockResponse().withWebSocketUpgrade(this))
if (this.updateWatchJob == null) {
CoroutineScope(Job()).launch {
startWatchingForUpdates(this)
}
}
return OkHttpClient().newWebSocket(request, listener)
}
private fun startWatchingForUpdates(scope: CoroutineScope) {
this.updateWatchJob = scope.launch {
server.updateFlow.collect {
println("Mock WebSocket is sending a message")
// Encode to JSON and send to websocket
val updateItems = listOf(it)
val encodedUpdateItem = gson.toJson(updateItems)
updateWebSocket?.send(encodedUpdateItem)
}
}
}
fun stopWatchingForUpdates() = runBlocking {
updateWatchJob?.cancelAndJoin()
}
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
super.onOpen(webSocket, response)
println("Mock WebSocket opened.")
this.updateWebSocket = webSocket
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
super.onClosed(webSocket, code, reason)
println("Mock WebSocket closed.")
this.updateWebSocket = null
}
}
class MockServerInterface(private val server: MockServer): APIInterface {
override suspend fun getVersion(): ResponseBody {
return server.version.toResponseBody("text/plain".toMediaType())
}
override suspend fun getConversations(): Response<List<Conversation>> {
return Response.success(server.conversations)
}
override suspend fun getMessages(
conversationGUID: String,
limit: Int?,
beforeMessageGUID: GUID?,
afterMessageGUID: GUID?
): Response<List<Message>> {
val messages = server.getMessagesForConversationGUID(conversationGUID)
return if (messages != null) {
Response.success(messages)
} else {
Response.error(500, "GUID not found".toResponseBody())
}
}
override suspend fun sendMessage(request: SendMessageRequest): Response<SendMessageResponse> {
val message = server.sendMessage(request.body, request.conversationGUID)
val response = SendMessageResponse(message.guid)
return Response.success(response)
}
override suspend fun markConversation(conversationGUID: String): Response<Void> {
server.markConversationAsRead(conversationGUID)
return Response.success(null)
}
override suspend fun fetchAttachment(guid: String, preview: Boolean): ResponseBody {
TODO("Not yet implemented")
}
override suspend fun uploadAttachment(
filename: String,
body: RequestBody
): Response<UploadAttachmentResponse> {
TODO("Not yet implemented")
}
override suspend fun authenticate(request: AuthenticationRequest): Response<AuthenticationResponse> {
// Anything goes!
val response = AuthenticationResponse(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
"eyJ1c2VybmFtZSI6InRlc3QiLCJleHAiOjE3MDk3OTQ5NjB9." +
"82UcI1gB4eARmgrKwAY6JnbEdWLXou1GWp29scnUhi8"
)
return Response.success(response)
}
}

View File

@@ -0,0 +1,29 @@
package android.util
public class Log {
companion object {
@JvmStatic
fun d(tag: String, msg: String): Int {
println("DEBUG: $tag: $msg")
return 0
}
@JvmStatic
fun i(tag: String, msg: String): Int {
println("INFO: $tag: $msg")
return 0
}
@JvmStatic
fun w(tag: String, msg: String): Int {
println("WARN: $tag: $msg")
return 0
}
@JvmStatic
fun e(tag: String, msg: String): Int {
println("ERROR: $tag: $msg")
return 0
}
}
}

16
android/build.gradle Normal file
View File

@@ -0,0 +1,16 @@
buildscript {
ext {
kotlin_version = '1.8.22'
realm_version = '1.10.0'
hilt_version = '2.44'
}
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.0.2' apply false
id 'com.android.library' version '8.0.2' apply false
id 'org.jetbrains.kotlin.android' version "${kotlin_version}" apply false
id 'io.realm.kotlin' version "${realm_version}" apply false
id 'com.google.dagger.hilt.android' version "${hilt_version}" apply false
}

25
android/gradle.properties Normal file
View File

@@ -0,0 +1,25 @@
# 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
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.defaults.buildfeatures.buildconfig=true
android.nonFinalResIds=false

Binary file not shown.

View File

@@ -0,0 +1,6 @@
#Sun Jun 11 18:08:06 PDT 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

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