Compare commits
396 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a850c9d612 | |||
| a51ff2a7c2 | |||
| 7cf2724a75 | |||
| f0ec6b8cb4 | |||
| f38702bc95 | |||
| a52c2e0909 | |||
| 9765994f14 | |||
| 5fd94489af | |||
| e807528466 | |||
| 7c117eb52e | |||
| 1febd91c2c | |||
| 9a3c808095 | |||
| 6ccef24512 | |||
| 61c1b690ba | |||
| 2f58283e26 | |||
| be2e3ea9d9 | |||
| fc69c387c5 | |||
| 68bb94aa0b | |||
| f4402292a1 | |||
| e650cffde7 | |||
| cbd9dccf1a | |||
| 1a5f13f2b8 | |||
| 87e986707d | |||
| b5ba0b1f7a | |||
| bc51bf03a1 | |||
| 8304b68a64 | |||
| 6261351598 | |||
| 955ff95520 | |||
| 754ad3282d | |||
| f901077067 | |||
| 778d4b6650 | |||
| e8256a9e57 | |||
| 4e8b161d26 | |||
| 74d1a7f54b | |||
| 4b497aaabc | |||
| 6caf008a39 | |||
| d20afef370 | |||
| 357be5cdf4 | |||
| 4db28222a6 | |||
| 469fd8fa13 | |||
| f09f45a66f | |||
| 481ac7357c | |||
| 27c6ac1c47 | |||
| acbcf2f992 | |||
| 577e8491c9 | |||
| 034026e88a | |||
| 7fe2701272 | |||
| 6a4054c15a | |||
| 8216d7c706 | |||
| c710c6e053 | |||
| a07f3dcd23 | |||
| 46755a07ef | |||
| b0dfc4146c | |||
| b2f8abfbff | |||
| 7675894ba7 | |||
| fc02d86a68 | |||
| 236070ccc9 | |||
| 0595fbc651 | |||
| 44fa638b1c | |||
| 8fcc7609b9 | |||
| 54f7f3a4db | |||
| 8257b8dbd6 | |||
| 92d5b99853 | |||
| 7992c03fb6 | |||
| 5f37f82a33 | |||
| 41c5776d98 | |||
| 54df338ce0 | |||
| 0128723765 | |||
| 5da92a90d4 | |||
| eb4426e473 | |||
| c1507e9ee1 | |||
| c30330a444 | |||
| 402b5a5f80 | |||
| f0fd738935 | |||
| f82123a454 | |||
| f0029d02e1 | |||
| cc59fe4996 | |||
| eaa5966e99 | |||
| f2353461b3 | |||
| f277fcd341 | |||
| ee32a0398f | |||
| 126a4cc55f | |||
| b38df68eb2 | |||
| 3ee94a3bea | |||
| b5a2f318b4 | |||
| f239d1de19 | |||
| 28738a1e92 | |||
| 00bbc3b330 | |||
| 73508bea9e | |||
| a93a773071 | |||
| fc62f0533d | |||
| 06b27c041a | |||
| da813806bb | |||
| 16db2caacc | |||
| 0b7b35b301 | |||
| 6f90e1c749 | |||
| b7fabd6c05 | |||
| 885c96172d | |||
| 8ff95f4bf9 | |||
| e51fa3abeb | |||
| e9bda39d8a | |||
| 7d0dfb455a | |||
| 201982170f | |||
| 54b76109c2 | |||
| 8cdcb049cf | |||
|
|
911454aafb | ||
| 43b668e9a2 | |||
|
|
c7d620c1b5 | ||
|
|
0e034898b2 | ||
|
|
8115f94121 | ||
| 5fa6c86a17 | |||
| 356a1b85b9 | |||
| 3e43bd1434 | |||
| c878141e61 | |||
| 44bc7c0cb4 | |||
| 349a644b0e | |||
| 742703cb8e | |||
| 3197814098 | |||
| 21703b9f8e | |||
| 6e14585a12 | |||
| b043ff6f08 | |||
| 9e3e6dc66f | |||
| bb74604a74 | |||
| e73cf321c0 | |||
| 5a399cc6ca | |||
| f6bb1a9b57 | |||
| bb19db17cd | |||
| 9f84969ff5 | |||
| 3379198940 | |||
| 0dece34012 | |||
| 4ebd310b7a | |||
| ccfea2883c | |||
| 3b6666cfc2 | |||
| 3b30cb77c8 | |||
| a70adbb7f1 | |||
| 4170f13092 | |||
| d33b50cfb5 | |||
| fa6c7c50b7 | |||
| f0b7cff226 | |||
| e1c579d23b | |||
| 16102f9f94 | |||
| 54ca001892 | |||
| 2041d3ce63 | |||
| c70ae00d5b | |||
| 032573d23b | |||
| 75fe4d4608 | |||
| 800090542d | |||
| 9d591dffc5 | |||
| 45aaf55804 | |||
| 2db0e3136e | |||
| 31eeb8659a | |||
| 4d466f0d26 | |||
| b2049fb432 | |||
| bb04bc4352 | |||
| aace2a8dfc | |||
| 9c013c3702 | |||
| 741932c67d | |||
| 45d873907f | |||
| dece6f1abc | |||
| 7cceb5b92d | |||
| 3f03937ca4 | |||
| d706435103 | |||
| e1ec237053 | |||
| a9c2f5d93e | |||
| 1dc6f0ec1b | |||
| e97edc10b7 | |||
| 283e6c2218 | |||
| f040b95827 | |||
| 7352efbcfd | |||
| 78eb946109 | |||
| 1a5bb874dc | |||
| 1420d96a20 | |||
| 4f40be205d | |||
| 269271835f | |||
| ff03e73758 | |||
| 2d43b87839 | |||
| 6fb88c3a0d | |||
| 137da5b3d1 | |||
| f3e59b9951 | |||
| 8dbe36fde1 | |||
| 930f905efc | |||
| 2f4e9b7c07 | |||
| 501bd3f604 | |||
| 54790d1d70 | |||
| 4ddc0dca39 | |||
| 1d3b2f25ba | |||
| 8cd72d9417 | |||
| 9e8c976a0e | |||
| 77e1078d6a | |||
| 1a2dad08a5 | |||
| 2e55f3ac9e | |||
| cbc7679f58 | |||
| 595c7a764b | |||
| e55b29eb4d | |||
| 2b5df53cc3 | |||
| 831e490eb4 | |||
| c02d4ecdf3 | |||
| 0d4c2e5104 | |||
| 77177e07aa | |||
| 83eb97fd9c | |||
| 1ed7f5bda3 | |||
| 4ad9613827 | |||
| 7339b49759 | |||
| 95c2e855dd | |||
| f377bbb7f9 | |||
| 4aa6e53e3a | |||
| 819b852c1f | |||
| f38e2a9798 | |||
| d4cc3358b7 | |||
| 3e9e8fb3d0 | |||
| 7ccdbced30 | |||
| 786d982ce0 | |||
| dd91746310 | |||
| d3dfffd652 | |||
| 8e87c2bce2 | |||
| 21c926456d | |||
| d843127c6d | |||
| 518608a04e | |||
| 0d61b6f2d7 | |||
| e44120712f | |||
| 0f565756df | |||
| 26d54f91d5 | |||
| ecf66131e9 | |||
| ef0312ccbd | |||
| 461c37bd20 | |||
| 410182eab8 | |||
| 2519bc05ad | |||
| 07b55f8615 | |||
| 05b4beb2fb | |||
| 2106bce755 | |||
| 2314713bb4 | |||
| 1c2f09e81b | |||
| f6ac3b5a58 | |||
| 13a78ccd47 | |||
| fd4c43d585 | |||
| f80d1a609b | |||
| a7e88bd3c3 | |||
| bdf76ca725 | |||
| 4c7c31ab8d | |||
| e976b3db4c | |||
| 3e1fa63fdf | |||
| 56fba9b72c | |||
| 59cfc8008b | |||
| 907a69385d | |||
| 101694ddbc | |||
| 7200ae54e4 | |||
| a1250c8ebe | |||
| 4eff88a51b | |||
| e7d837d68c | |||
| c189e5f9e3 | |||
| 9c245a5b52 | |||
| 6375284d9e | |||
| 1e9b570993 | |||
| cecfd7cd76 | |||
| 49f8b81b9c | |||
| 84f782cc03 | |||
| 22554a7644 | |||
| ef74df9f28 | |||
| 82192ffbe5 | |||
| fe32efef2c | |||
| 0c6b55fa38 | |||
| b1f171136a | |||
| 89c9ffc187 | |||
| f7d094fcd6 | |||
|
|
d0e1f51b6b | ||
| dd9025cc10 | |||
| 68ff158d6c | |||
| 6a7d376aa9 | |||
| fddc45c62a | |||
| 16c202734c | |||
| bfc6fdddc1 | |||
| 5d3d2f194a | |||
| 146fac2759 | |||
| a8104c379c | |||
| 793faab721 | |||
| 5d26ea9569 | |||
| 89f8d21ebb | |||
| 53d4604b63 | |||
| ab44a169e6 | |||
| 8f523fd7fc | |||
| c4c6e4245d | |||
| f79cbbbc85 | |||
| 86601b027a | |||
| fac9b1ffe6 | |||
| 75d4767009 | |||
| 1eb08ba464 | |||
| 0e8b8f339a | |||
| 6b9f528cbf | |||
| 9007b4503f | |||
| 030e86e205 | |||
|
|
b7312bccb9 | ||
| a11dd27ef8 | |||
| c1fef50c0c | |||
| da36d9da91 | |||
| cabd3b502a | |||
| 0dde0b9c53 | |||
| a2caa2ddca | |||
| cf4195858e | |||
| 48dcf9daca | |||
| 3e878ced4e | |||
| 0b2811dc9f | |||
| 634540a703 | |||
| d2afecafcf | |||
| 50e9971694 | |||
| b47132fd05 | |||
| 95b358e66e | |||
| a1349eff1b | |||
| fa76c7eac1 | |||
| 63876104aa | |||
| 58c84f6472 | |||
| c666083e4b | |||
| 413fe338ca | |||
| 72527088cc | |||
| c65803845b | |||
|
|
831636216d | ||
| a8043e53b3 | |||
|
|
147dc15d1d | ||
| d8ca07f92a | |||
| 416949095c | |||
|
|
87d65e294b | ||
| f222078a9d | |||
| a0e8e049f0 | |||
| 7895a3a2e0 | |||
| b92d1a2892 | |||
| e9a9d1c064 | |||
| 56ad3a49ea | |||
| b92c683a0e | |||
| f13a809f1e | |||
| 6a0b7ca225 | |||
| 5c9f580dff | |||
| b77020e23c | |||
| 1fce2c7cb3 | |||
| 7611bedef7 | |||
|
|
37ff0b375f | ||
|
|
d071e68a56 | ||
| ac27ac2d14 | |||
| 943f52ac45 | |||
| 06046ac266 | |||
| 191cffd4cf | |||
| 732e9b9667 | |||
|
|
a4c25df183 | ||
|
|
27de41ddb2 | ||
|
|
84dbb7f006 | ||
| 3613aac4c1 | |||
| 64c5169542 | |||
| 2d415a1170 | |||
| cdf3d922f7 | |||
| 63ac783e18 | |||
| 6bbcf8cc63 | |||
| 53870e25a9 | |||
| a2b14c88ea | |||
|
|
2f5d50188b | ||
|
|
56ae7982c6 | ||
|
|
bc9e4f52b4 | ||
|
|
3082c4ab19 | ||
| ba8f76f4bd | |||
|
|
e5b78d62f0 | ||
|
|
3ca9abcccd | ||
|
|
cad3425327 | ||
|
|
83ba072a9d | ||
|
|
bd01480ad6 | ||
|
|
c7087a394e | ||
| ebad248c1c | |||
| e161eedef3 | |||
| 7a3303da06 | |||
| 641e4c53fa | |||
|
|
4d51ba7dd2 | ||
|
|
f64ffcb8cc | ||
|
|
3c99b647d2 | ||
| bb169c3e1c | |||
|
|
61384cff26 | ||
|
|
f006af9863 | ||
|
|
4f7a6d1b87 | ||
| 6c089f737b | |||
|
|
de852a926d | ||
|
|
e6314b0f80 | ||
|
|
0cb907e2ad | ||
|
|
90775ebbba | ||
|
|
6aaa2ff5b3 | ||
|
|
dba4910a82 | ||
|
|
3186f1948a | ||
|
|
4f5cd058c5 | ||
|
|
ddec4be8d6 | ||
|
|
464e9fe22c | ||
| 1b12faddf8 | |||
| 182e240291 | |||
| baffa7b035 | |||
|
|
5ced6151c2 | ||
|
|
6c9996dfa1 | ||
|
|
b92860b011 | ||
| e08e9d738c | |||
|
|
f462ee68ca | ||
|
|
ce7e6e7dd8 | ||
|
|
7a3dee7073 | ||
|
|
2d0fd5b290 | ||
|
|
58777807bc |
18
.gitignore
vendored
@@ -1,17 +1 @@
|
|||||||
*.iml
|
target/
|
||||||
.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/
|
|
||||||
|
|||||||
66
README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Kordophone Monorepo
|
||||||
|
|
||||||
|
Kordophone is an iMessage bridge: a lightweight server runs on a Mac and exposes an HTTP API so non‑Apple 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.
|
||||||
|
|
||||||
|
> Important: Interfacing with iMessage on macOS involves private APIs and restricted entitlements. See `server/README.md` for details and safety notes.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
Top‑level projects in this monorepo:
|
||||||
|
|
||||||
|
- `server/` — macOS daemon that bridges to iMessage and exposes an HTTP REST API. Written in Objective‑C/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/` — Go‑based 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 (D‑Bus 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
|
||||||
|
```
|
||||||
|
|
||||||
|
The mock server listens on `http://localhost:5738`.
|
||||||
|
|
||||||
|
2) Point a client at the mock server:
|
||||||
|
|
||||||
|
- 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`.
|
||||||
|
- 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.
|
||||||
|
- macOS (Cocoa): build the app in `osx/` (see `osx/README.md`).
|
||||||
|
|
||||||
|
To use a real Mac server instead, set your client’s server URL to the host running `server/` with the correct scheme/port and authentication.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Below are brief notes. Each subproject’s README has more detail.
|
||||||
|
|
||||||
|
- Server (macOS): open `server/MessagesBridge.xcodeproj` in Xcode. See `server/README.md` for entitlements, SSL, and running.
|
||||||
|
- 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`.
|
||||||
|
|
||||||
17
android/.gitignore
vendored
Normal 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/
|
||||||
0
.idea/.gitignore → android/.idea/.gitignore
generated
vendored
0
.idea/gradle.xml → android/.idea/gradle.xml
generated
0
.idea/misc.xml → android/.idea/misc.xml
generated
0
.idea/vcs.xml → android/.idea/vcs.xml
generated
46
android/README.md
Normal 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 app’s 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 app’s server URL to the mock address and run. The mock implements the Kordophone server API; see centralized API documentation (planned under `api/`).
|
||||||
0
app/.gitignore → android/app/.gitignore
vendored
@@ -40,7 +40,10 @@
|
|||||||
android:value="" />
|
android:value="" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service android:name=".UpdateMonitorService" />
|
<service
|
||||||
|
android:name=".UpdateMonitorService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
BIN
android/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
@@ -1,6 +1,5 @@
|
|||||||
package net.buzzert.kordophonedroid.ui.attachments
|
package net.buzzert.kordophonedroid.ui.attachments
|
||||||
|
|
||||||
import androidx.compose.foundation.Indication
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -8,16 +7,15 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.material.Scaffold
|
import androidx.compose.material.Scaffold
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import coil.compose.SubcomposeAsyncImage
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import net.buzzert.kordophonedroid.ui.LocalNavController
|
import net.buzzert.kordophonedroid.ui.LocalNavController
|
||||||
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
|
import net.buzzert.kordophonedroid.ui.theme.KordophoneTopAppBar
|
||||||
@@ -41,12 +39,19 @@ fun AttachmentViewer(attachmentGuid: String) {
|
|||||||
Column(modifier = Modifier.padding(padding)) {
|
Column(modifier = Modifier.padding(padding)) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
AsyncImage(
|
SubcomposeAsyncImage(
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
.data(data)
|
.data(data)
|
||||||
.crossfade(true)
|
.crossfade(true)
|
||||||
.build(),
|
.build(),
|
||||||
contentDescription = "",
|
contentDescription = "",
|
||||||
|
loading = {
|
||||||
|
Box {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.zoomable(zoomState)
|
.zoomable(zoomState)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
14
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
BIN
android/app/src/main/res/drawable/kordophone_ic.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
BIN
android/app/src/main/res/drawable/kordophone_ic_small.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 659 KiB After Width: | Height: | Size: 659 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 840 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#3D3D3D</color>
|
||||||
|
</resources>
|
||||||
BIN
android/backend/chat-cache-test
Normal file
@@ -97,6 +97,7 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
|||||||
date = conversation.date
|
date = conversation.date
|
||||||
unreadCount = conversation.unreadCount
|
unreadCount = conversation.unreadCount
|
||||||
lastMessagePreview = conversation.lastMessagePreview
|
lastMessagePreview = conversation.lastMessagePreview
|
||||||
|
lastMessageGUID = conversation.lastMessageGUID
|
||||||
}
|
}
|
||||||
} catch (e: NoSuchElementException) {
|
} catch (e: NoSuchElementException) {
|
||||||
// Conversation does not exist. Copy it to the realm.
|
// Conversation does not exist. Copy it to the realm.
|
||||||
@@ -121,6 +122,10 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation, outgoing: Boolean = false) {
|
fun writeMessages(messages: List<ModelMessage>, conversation: ModelConversation, outgoing: Boolean = false) {
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val dbConversation = getManagedConversationByGuid(conversation.guid)
|
val dbConversation = getManagedConversationByGuid(conversation.guid)
|
||||||
realm.writeBlocking {
|
realm.writeBlocking {
|
||||||
messages
|
messages
|
||||||
@@ -128,8 +133,17 @@ class CachedChatDatabase (private val realmConfig: RealmConfiguration) {
|
|||||||
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
|
.map { copyToRealm(it, updatePolicy = UpdatePolicy.ALL) }
|
||||||
|
|
||||||
findLatest(dbConversation)?.let {
|
findLatest(dbConversation)?.let {
|
||||||
it.lastMessagePreview = messages.last().displayText
|
val lastMessage = messages.maxByOrNull { it.date }!!
|
||||||
it.date = messages.last().date.toInstant().toRealmInstant()
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
package net.buzzert.kordophone.backend.db.model
|
package net.buzzert.kordophone.backend.db.model
|
||||||
|
|
||||||
import io.realm.kotlin.Realm
|
|
||||||
import io.realm.kotlin.ext.realmListOf
|
import io.realm.kotlin.ext.realmListOf
|
||||||
import io.realm.kotlin.ext.realmSetOf
|
|
||||||
import io.realm.kotlin.ext.toRealmList
|
import io.realm.kotlin.ext.toRealmList
|
||||||
import io.realm.kotlin.types.RealmInstant
|
import io.realm.kotlin.types.RealmInstant
|
||||||
import io.realm.kotlin.types.RealmList
|
import io.realm.kotlin.types.RealmList
|
||||||
import io.realm.kotlin.types.RealmObject
|
import io.realm.kotlin.types.RealmObject
|
||||||
import io.realm.kotlin.types.RealmSet
|
|
||||||
import io.realm.kotlin.types.annotations.PrimaryKey
|
import io.realm.kotlin.types.annotations.PrimaryKey
|
||||||
import net.buzzert.kordophone.backend.model.GUID
|
import net.buzzert.kordophone.backend.model.GUID
|
||||||
import org.mongodb.kbson.ObjectId
|
import org.mongodb.kbson.ObjectId
|
||||||
import java.time.Instant
|
|
||||||
import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
|
import net.buzzert.kordophone.backend.model.Conversation as ModelConversation
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@@ -24,6 +20,7 @@ open class Conversation(
|
|||||||
var date: RealmInstant,
|
var date: RealmInstant,
|
||||||
var unreadCount: Int,
|
var unreadCount: Int,
|
||||||
|
|
||||||
|
var lastMessageGUID: String?,
|
||||||
var lastMessagePreview: String?,
|
var lastMessagePreview: String?,
|
||||||
): RealmObject
|
): RealmObject
|
||||||
{
|
{
|
||||||
@@ -35,10 +32,11 @@ open class Conversation(
|
|||||||
date = RealmInstant.now(),
|
date = RealmInstant.now(),
|
||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
lastMessagePreview = null,
|
lastMessagePreview = null,
|
||||||
|
lastMessageGUID = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun toConversation(): ModelConversation {
|
fun toConversation(): ModelConversation {
|
||||||
val conversation = ModelConversation(
|
return ModelConversation(
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
participants = participants.toList(),
|
participants = participants.toList(),
|
||||||
date = Date.from(date.toInstant()),
|
date = Date.from(date.toInstant()),
|
||||||
@@ -46,9 +44,8 @@ open class Conversation(
|
|||||||
guid = guid,
|
guid = guid,
|
||||||
lastMessagePreview = lastMessagePreview,
|
lastMessagePreview = lastMessagePreview,
|
||||||
lastMessage = null,
|
lastMessage = null,
|
||||||
|
lastFetchedMessageGUID = lastMessageGUID
|
||||||
)
|
)
|
||||||
|
|
||||||
return conversation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -57,6 +54,10 @@ open class Conversation(
|
|||||||
val o = other as Conversation
|
val o = other as Conversation
|
||||||
return guid == o.guid
|
return guid == o.guid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return guid.hashCode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun ModelConversation.toDatabaseConversation(): Conversation {
|
fun ModelConversation.toDatabaseConversation(): Conversation {
|
||||||
@@ -67,6 +68,7 @@ fun ModelConversation.toDatabaseConversation(): Conversation {
|
|||||||
date = from.date.toInstant().toRealmInstant()
|
date = from.date.toInstant().toRealmInstant()
|
||||||
unreadCount = from.unreadCount
|
unreadCount = from.unreadCount
|
||||||
lastMessagePreview = from.lastMessagePreview
|
lastMessagePreview = from.lastMessagePreview
|
||||||
|
lastMessageGUID = from.lastFetchedMessageGUID
|
||||||
guid = from.guid
|
guid = from.guid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,8 @@ data class Conversation(
|
|||||||
|
|
||||||
@SerializedName("lastMessage")
|
@SerializedName("lastMessage")
|
||||||
var lastMessage: Message?,
|
var lastMessage: Message?,
|
||||||
|
|
||||||
|
var lastFetchedMessageGUID: String?,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun generate(): Conversation {
|
fun generate(): Conversation {
|
||||||
@@ -38,6 +40,7 @@ data class Conversation(
|
|||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
lastMessagePreview = null,
|
lastMessagePreview = null,
|
||||||
lastMessage = null,
|
lastMessage = null,
|
||||||
|
lastFetchedMessageGUID = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,7 @@ data class Conversation(
|
|||||||
participants == o.participants &&
|
participants == o.participants &&
|
||||||
displayName == o.displayName &&
|
displayName == o.displayName &&
|
||||||
unreadCount == o.unreadCount &&
|
unreadCount == o.unreadCount &&
|
||||||
lastMessagePreview == o.lastMessagePreview
|
lastFetchedMessageGUID == o.lastFetchedMessageGUID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +72,8 @@ data class Conversation(
|
|||||||
result = 31 * result + participants.hashCode()
|
result = 31 * result + participants.hashCode()
|
||||||
result = 31 * result + (displayName?.hashCode() ?: 0)
|
result = 31 * result + (displayName?.hashCode() ?: 0)
|
||||||
result = 31 * result + unreadCount
|
result = 31 * result + unreadCount
|
||||||
result = 31 * result + (lastMessagePreview?.hashCode() ?: 0)
|
|
||||||
result = 31 * result + (lastMessage?.hashCode() ?: 0)
|
result = 31 * result + (lastMessage?.hashCode() ?: 0)
|
||||||
|
result = 31 * result + (lastFetchedMessageGUID?.hashCode() ?: 0)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,9 +182,7 @@ class ChatRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = withErrorChannelHandling {
|
suspend fun synchronizeConversation(conversation: Conversation, limit: Int = 15) = withErrorChannelHandling {
|
||||||
// TODO: Should only fetch messages after the last GUID we know about.
|
val messages = fetchMessages(conversation, limit = limit, afterGUID = conversation.lastFetchedMessageGUID)
|
||||||
// But keep in mind that outgoing message GUIDs are fake...
|
|
||||||
val messages = fetchMessages(conversation, limit = limit)
|
|
||||||
database.writeMessages(messages, conversation)
|
database.writeMessages(messages, conversation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,10 +231,10 @@ class ChatRepository(
|
|||||||
private suspend fun fetchMessages(
|
private suspend fun fetchMessages(
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
limit: Int? = null,
|
limit: Int? = null,
|
||||||
before: Message? = null,
|
beforeGUID: String? = null,
|
||||||
after: Message? = null,
|
afterGUID: String? = null,
|
||||||
): List<Message> {
|
): List<Message> {
|
||||||
return apiInterface.getMessages(conversation.guid, limit, before?.guid, after?.guid)
|
return apiInterface.getMessages(conversation.guid, limit, beforeGUID, afterGUID)
|
||||||
.bodyOnSuccessOrThrow()
|
.bodyOnSuccessOrThrow()
|
||||||
.onEach { it.conversation = conversation }
|
.onEach { it.conversation = conversation }
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.runBlocking
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import net.buzzert.kordophone.backend.db.CachedChatDatabase
|
import net.buzzert.kordophone.backend.db.CachedChatDatabase
|
||||||
import net.buzzert.kordophone.backend.model.Message
|
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.APIClient
|
||||||
import net.buzzert.kordophone.backend.server.APIInterface
|
import net.buzzert.kordophone.backend.server.APIInterface
|
||||||
import net.buzzert.kordophone.backend.server.Authentication
|
import net.buzzert.kordophone.backend.server.Authentication
|
||||||
@@ -85,13 +86,18 @@ class BackendTests {
|
|||||||
val (repository, mockServer) = mockRepository()
|
val (repository, mockServer) = mockRepository()
|
||||||
|
|
||||||
val conversation = mockServer.addTestConversations(1).first()
|
val conversation = mockServer.addTestConversations(1).first()
|
||||||
val outgoingMessage = MockServer.generateMessage(conversation)
|
val generatedMessage = MockServer.generateMessage(conversation)
|
||||||
|
val outgoingMessage = OutgoingMessage(
|
||||||
|
body = generatedMessage.text,
|
||||||
|
conversation = conversation,
|
||||||
|
attachmentUris = setOf(),
|
||||||
|
attachmentDataSource = { null },
|
||||||
|
)
|
||||||
|
|
||||||
val guid = repository.enqueueOutgoingMessage(outgoingMessage, conversation)
|
repository.enqueueOutgoingMessage(outgoingMessage)
|
||||||
|
|
||||||
val event = repository.messageDeliveredChannel.first()
|
val event = repository.messageDeliveredChannel.first()
|
||||||
assertEquals(event.requestGuid, guid)
|
assertEquals(event.message.text, outgoingMessage.body)
|
||||||
assertEquals(event.message.text, outgoingMessage.text)
|
|
||||||
|
|
||||||
repository.close()
|
repository.close()
|
||||||
}
|
}
|
||||||
@@ -18,18 +18,19 @@ import net.buzzert.kordophone.backend.server.AuthenticationRequest
|
|||||||
import net.buzzert.kordophone.backend.server.AuthenticationResponse
|
import net.buzzert.kordophone.backend.server.AuthenticationResponse
|
||||||
import net.buzzert.kordophone.backend.server.SendMessageRequest
|
import net.buzzert.kordophone.backend.server.SendMessageRequest
|
||||||
import net.buzzert.kordophone.backend.server.SendMessageResponse
|
import net.buzzert.kordophone.backend.server.SendMessageResponse
|
||||||
|
import net.buzzert.kordophone.backend.server.UploadAttachmentResponse
|
||||||
import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL
|
import net.buzzert.kordophone.backend.server.authenticatedWebSocketURL
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
import okhttp3.WebSocket
|
import okhttp3.WebSocket
|
||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import retrofit2.Call
|
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
@@ -66,7 +67,8 @@ class MockServer {
|
|||||||
unreadCount = 0,
|
unreadCount = 0,
|
||||||
lastMessagePreview = null,
|
lastMessagePreview = null,
|
||||||
lastMessage = null,
|
lastMessage = null,
|
||||||
guid = UUID.randomUUID().toString()
|
guid = UUID.randomUUID().toString(),
|
||||||
|
lastFetchedMessageGUID = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +168,8 @@ class MockServerClient(private val server: MockServer): APIClient, WebSocketList
|
|||||||
private var updateWatchJob: Job? = null
|
private var updateWatchJob: Job? = null
|
||||||
private val gson: Gson = Gson()
|
private val gson: Gson = Gson()
|
||||||
|
|
||||||
|
override val isConfigured: Boolean = true
|
||||||
|
|
||||||
override fun getAPIInterface(): APIInterface {
|
override fun getAPIInterface(): APIInterface {
|
||||||
return MockServerInterface(server)
|
return MockServerInterface(server)
|
||||||
}
|
}
|
||||||
@@ -268,6 +272,13 @@ class MockServerInterface(private val server: MockServer): APIInterface {
|
|||||||
TODO("Not yet implemented")
|
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> {
|
override suspend fun authenticate(request: AuthenticationRequest): Response<AuthenticationResponse> {
|
||||||
// Anything goes!
|
// Anything goes!
|
||||||
val response = AuthenticationResponse(
|
val response = AuthenticationResponse(
|
||||||