diff --git a/.gitignore b/.gitignore index 990d28b..0ae1b7e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,18 @@ logs/chat-users.json client/dist/ dist/ docroot/dist/ +android/.gradle/ +android/.gradle-user/ +android/.kotlin/ +android/.idea/ +android/build/ +android/app/build/ +android/local.properties +android/key.properties +android/*.jks +android/*.keystore +android/*.hprof +*.hprof # Temporäre Bilder (werden nach 6 Stunden automatisch gelöscht) tmp/ diff --git a/ANDROID-APP-KONZEPT.md b/ANDROID-APP-KONZEPT.md new file mode 100644 index 0000000..1514872 --- /dev/null +++ b/ANDROID-APP-KONZEPT.md @@ -0,0 +1,396 @@ +# Android-App-Konzept fuer SingleChat + +## Zielbild + +SingleChat soll als echte Android-App verfuegbar werden, nicht nur als WebView-Wrapper. Die Android-App nutzt die bestehenden Backend-Endpunkte und spricht mit dem vorhandenen Socket.IO-Server dasselbe Ereignisprotokoll wie das Vue-Web-Frontend. + +Das Ziel fuer den ersten Release ist Funktionsgleichheit mit dem Kern-Chat: + +- Login mit Benutzername, Geschlecht, Alter und Land +- Anzeige aktiver Benutzer +- Suche nach Benutzern +- Einzelchat mit Textnachrichten +- Bildversand ueber bestehenden Upload-Endpunkt +- Inbox mit ungelesenen Chats +- Verlauf geoeffneter Konversationen +- Blockieren und Entblockieren von Benutzern +- Session-Wiederherstellung, Logout und 30-Minuten-Inaktivitaetslogik +- Feedback- und Partnerseiten optional im MVP, aber technisch ueber vorhandene REST-Endpunkte moeglich + +## Bestand Im Repo + +Das aktuelle System besteht aus: + +- Backend: Node.js/Express in `server/index.js` +- REST-Endpunkte in `server/routes.js` +- Socket.IO-Chat-Protokoll in `server/broadcast.js` +- Vue/Pinia-Webclient mit Socket.IO-Client in `client/src/stores/chat.js` +- Bild-Upload im Webclient ueber `client/src/components/ChatInput.vue` + +Der Server erlaubt Socket.IO mit `websocket` und `polling`. Das Web-Frontend nutzt aktuell absichtlich nur `polling`, vermutlich wegen Proxy-/WebSocket-Problemen. Fuer Android sollte WebSocket als bevorzugter Transport genutzt werden, mit Polling als Fallback. + +## Technische Empfehlung + +### App-Technologie + +Empfohlen: native Android-App mit Kotlin und Jetpack Compose. + +Gruende: + +- echte App-Erfahrung statt WebView +- robuste Hintergrund-/Reconnect-Logik +- gute Kontrolle ueber Cookies, Sessions und Uploads +- moderne UI mit Compose schneller wartbar +- bessere Basis fuer spaetere Push Notifications + +Alternative: React Native oder Flutter waeren moeglich, bringen aber fuer diese App keinen klaren Vorteil, weil das Protokoll einfach ist und Android explizit das Ziel ist. + +### Zielarchitektur + +```text +Android App + UI: Jetpack Compose + State: ViewModel + StateFlow + REST: Retrofit/OkHttp + Socket: Socket.IO Android Client + Session: OkHttp CookieJar + EncryptedSharedPreferences/DataStore + Images: Android Photo Picker + Multipart Upload + +Existing Backend + Express REST API + Socket.IO Chat Events + express-session Cookie connect.sid +``` + +## Backend-Anbindung + +### Basis-URL + +Production: + +```text +https://www.ypchat.net +``` + +Development: + +```text +http://10.0.2.2:3300 +``` + +`10.0.2.2` ist im Android Emulator der Host-Rechner. Auf echtem Geraet braucht es die lokale LAN-IP oder einen Dev-Tunnel. + +### REST-Endpunkte + +Die Android-App kann folgende vorhandene Endpunkte direkt verwenden: + +| Zweck | Methode | Pfad | Android-Nutzung | +|---|---:|---|---| +| Health Check | GET | `/api/health` | Diagnose/Verbindungscheck | +| Session holen | GET | `/api/session` | Cookie initialisieren, Session wiederherstellen, Express-Session-ID erhalten | +| Logout | POST | `/api/logout` | serverseitige Session beenden und Socket trennen | +| Laenderliste | GET | `/api/countries` | Login-Land-Auswahl und Flag-Code | +| Bild hochladen | POST | `/api/upload-image` | Multipart-Upload mit Session-Cookie | +| Bild abrufen | GET | `/api/image/:code` | Anzeige empfangener Bilder | +| Partner | GET | `/api/partners` | optionaler App-Screen | +| Feedback laden | GET | `/api/feedback` | optionaler Feedback-Screen | +| Feedback senden | POST | `/api/feedback` | optionaler Feedback-Screen | +| Feedback Admin Login | POST | `/api/feedback/admin-login` | optional, eher nicht MVP | +| Feedback Admin Logout | POST | `/api/feedback/admin-logout` | optional, eher nicht MVP | +| Feedback loeschen | DELETE | `/api/feedback/:id` | optional, eher nicht MVP | + +Wichtig: REST und Socket muessen dieselbe Cookie-Session verwenden. Android braucht daher einen gemeinsamen `OkHttpClient` mit persistenter Cookie-Verwaltung. + +## Socket.IO-Protokoll + +### Verbindungsaufbau + +Ablauf analog zum Web-Frontend: + +1. `GET /api/session` aufrufen, damit das Backend ein `connect.sid`-Cookie setzt und die `sessionId` zurueckgibt. +2. Socket.IO zu derselben Basis-URL verbinden. +3. Nach `connect` das Event `setSessionId` senden: + +```json +{ + "expressSessionId": "" +} +``` + +4. Server sendet `connected`. +5. Wenn `connected.loggedIn === true`, App-State wiederherstellen. +6. Sonst Login-Screen anzeigen. + +### Client sendet + +| Event | Payload | Zweck | +|---|---|---| +| `setSessionId` | `{ "expressSessionId": string }` | Socket mit Express-Session verknuepfen | +| `login` | `{ "userName": string, "gender": string, "age": number, "country": string, "expressSessionId": string }` | Chat-Login | +| `message` | `{ "toUserName": string, "message": string, "messageId": string }` | Textnachricht | +| `message` | `{ "toUserName": string, "message": imageCode, "messageId": string, "isImage": true, "imageUrl": string }` | Bildnachricht nach REST-Upload | +| `requestConversation` | `{ "withUserName": string }` | Konversation laden und als gelesen markieren | +| `userSearch` | `{ "nameIncludes"?, "minAge"?, "maxAge"?, "countries"?, "genders"? }` | Benutzer suchen | +| `requestHistory` | kein Payload | Chatverlauf-Liste laden | +| `requestOpenConversations` | kein Payload | Inbox/ungelesene Chats laden | +| `blockUser` | `{ "userName": string }` | Benutzer blockieren | +| `unblockUser` | `{ "userName": string }` | Benutzer entblockieren | + +### Server sendet + +| Event | Payload | Android-State | +|---|---|---| +| `connected` | `{ "sessionId": string, "loggedIn"?: boolean, "user"?: User }` | Session setzen, ggf. Login wiederherstellen | +| `loginSuccess` | `{ "sessionId": string, "user": User }` | Login-State setzen | +| `userList` | `{ "users": User[] }` | Online-Liste aktualisieren | +| `message` | `{ "from": string, "message": string, "messageId": string, "timestamp": string, "isImage"?, "imageUrl"?, "imageCode"? }` | Nachricht empfangen | +| `messageSent` | `{ "messageId": string, "to": string }` | lokale Nachricht bestaetigen | +| `conversation` | `{ "with": string, "messages": Message[] }` | Konversation anzeigen | +| `searchResults` | `{ "results": User[] }` | Suchergebnisse anzeigen | +| `historyResults` | `{ "results": HistoryItem[] }` | Verlauf anzeigen | +| `inboxResults` | `{ "results": InboxItem[] }` | Inbox anzeigen | +| `unreadChats` | `{ "count": number }` | Badge aktualisieren | +| `commandResult` | `{ "lines": string[], "kind": string }` | Admin-/Slash-Command-Hinweise anzeigen | +| `commandTable` | `{ "title": string, "columns": string[], "rows": unknown[][] }` | Admin-/Statistik-Tabelle anzeigen | +| `userBlocked` | `{ "userName": string }` | Blockierstatus setzen | +| `userUnblocked` | `{ "userName": string }` | Blockierstatus entfernen | +| `error` | `{ "message": string }` | Snackbar/Dialog anzeigen | + +## Android-Modulstruktur + +Vorschlag fuer ein neues Modul oder separates Repo: + +```text +android/ + app/ + src/main/ + java/net/ypchat/app/ + MainActivity.kt + YpChatApp.kt + core/ + Config.kt + SessionCookieJar.kt + NetworkModule.kt + data/ + api/ + RestApi.kt + SocketClient.kt + model/ + UserDto.kt + MessageDto.kt + ConversationDto.kt + repository/ + ChatRepository.kt + FeedbackRepository.kt + ui/ + login/ + chat/ + users/ + search/ + inbox/ + history/ + feedback/ + common/ +``` + +### Verantwortlichkeiten + +- `RestApi`: Retrofit-Definitionen fuer `/api/*` +- `SocketClient`: kapselt Socket.IO-Verbindung, Event-Handler und Emits +- `ChatRepository`: verbindet REST, Socket und lokalen App-State +- `ChatViewModel`: bietet `StateFlow` fuer Compose +- `SessionCookieJar`: persistiert `connect.sid`, damit REST und Socket dieselbe Session nutzen +- `ImageUploader`: Photo Picker, Komprimierung falls noetig, Multipart Upload + +## UI-Konzept + +### Navigation + +MVP-Screens: + +- Login +- Online-Benutzer +- Suche +- Chat +- Inbox +- Verlauf +- Profil/Logout + +Optional: + +- Feedback +- Partner +- Regeln/Sicherheit/FAQ als native Info-Screens oder WebContent aus statischen Texten + +### Mobile UX + +Der Webclient ist desktop-/browsernah. Die App sollte mobiler denken: + +- Startet in Login oder zuletzt aktiver Chat-Ansicht +- Bottom Navigation fuer `Online`, `Suche`, `Inbox`, `Verlauf` +- Chat-Screen mit fester Eingabezeile unten +- Online-Status und Flagge direkt in User-Zeilen +- Unread-Badge auf Inbox-Tab +- Bildauswahl ueber Android Photo Picker +- Fehlermeldungen als Snackbar, kritische Session-Fehler als Dialog + +## Session- und Reconnect-Konzept + +### Persistenz + +Gespeichert werden lokal: + +- `connect.sid` Cookie +- letzter bekannter Login-State fuer UI-Skeleton +- Logout-Marker, analog `singlechat_logged_out` im Web +- keine Chatnachrichten dauerhaft im MVP, weil Backend diese aktuell nur im Arbeitsspeicher haelt + +### Reconnect + +Empfohlener Ablauf: + +1. App startet. +2. Wenn kein Logout-Marker vorhanden: `GET /api/session`. +3. Wenn Session `loggedIn`: Socket verbinden und `setSessionId` senden. +4. Wenn Socket reconnectet: erneut `GET /api/session`, dann `setSessionId`. +5. Bei `connect_error`: exponentielles Retry mit sichtbarem Offline-Banner. +6. Bei Logout: `POST /api/logout`, Socket disconnect, Cookie loeschen, Logout-Marker setzen. + +### Inaktivitaet + +Backend und Webclient verwenden 30 Minuten. Android sollte dieselbe Regel im UI spiegeln: + +- Timer startet nach `loginSuccess` oder Session-Restore. +- Timer wird bei Senden, Empfangen, Suche und Conversation Requests zurueckgesetzt. +- Bei Ablauf: lokaler Logout und optional `POST /api/logout`. + +## Bildversand + +Ablauf: + +1. Benutzer waehlt Bild per Photo Picker. +2. App prueft MIME-Type und Groesse. +3. Optional: Bild auf sinnvolle Chat-Groesse komprimieren, z.B. max. 1600 px Kantenlaenge. +4. `POST /api/upload-image` als Multipart `image`. +5. Backend antwortet mit: + +```json +{ + "success": true, + "code": "", + "url": "/api/image/" +} +``` + +6. App sendet Socket-Event `message` mit `isImage: true`, `message: code`, `imageUrl: url`. +7. Anzeige erfolgt ueber volle URL `https://www.ypchat.net/api/image/`. + +Hinweis: Der Backend-Endpunkt erlaubt 5 MB Upload und haelt Bilder temporaer fuer 6 Stunden. + +## Backend-Anpassungen Vor Android-Release + +Die App kann grundsaetzlich mit dem aktuellen Backend starten. Sinnvolle kleine Anpassungen wuerden die Mobile-Integration aber robuster machen: + +- CORS ist fuer mobile Apps weniger kritisch, aber Socket.IO-Origin-Handling sollte getestet werden, weil native Clients oft keinen Browser-Origin senden. +- Session-Handling sollte fuer Android explizit dokumentiert werden: `GET /api/session` vor Socket-Verbindung. +- WebSocket ueber Apache sollte sauber funktionieren. Android kann Polling fallbacken, aber echte WebSocket-Verbindung ist fuer Akku und Latenz besser. +- `/api/upload-image` hat aktuell einen Fallback auf den zuletzt aktiven Client. Fuer Mobile waere sauberer: eindeutig ueber `req.sessionID` validieren und keine Aktivitaets-Heuristik verwenden. +- Nachrichten und Konversationen liegen aktuell im Arbeitsspeicher. Fuer eine App mit Reconnect/Background-Nutzung sollte mittelfristig Persistenz ergaenzt werden. +- Optional: API-Versionierung einfuehren, z.B. `/api/v1/session`, bevor App-Versionen langfristig im Umlauf sind. + +## Datenschutz Und Store-Themen + +Vor Veroeffentlichung im Play Store beachten: + +- Datenschutzerklaerung direkt in App verlinken +- klare Alters-/Community-Regeln im Onboarding +- Hinweis, dass Chatnachrichten temporaer serverseitig verarbeitet werden +- Bild-Upload transparent erklaeren +- Melde-/Blockierfunktion prominent erreichbar machen +- Android `INTERNET` Permission erforderlich +- Keine Speicherpermission noetig, wenn Android Photo Picker genutzt wird + +Wenn die App spaeter Push Notifications bekommt, braucht es FCM, Datenschutz-Ergaenzung und serverseitige Device-Token-Verwaltung. + +## MVP-Backlog + +### Phase 1: Android-Projekt + +- Gradle/Kotlin/Compose-Projekt anlegen +- App-Theme und Basisnavigation erstellen +- `Config` fuer Dev/Prod-Basis-URL +- OkHttp, Retrofit und Socket.IO-Client einrichten +- Persistente CookieJar implementieren + +### Phase 2: Session Und Login + +- `GET /api/session` +- Socket-Verbindung mit `setSessionId` +- Login-Screen +- `login` Event +- `connected`, `loginSuccess`, `error` +- Logout mit `/api/logout` + +### Phase 3: Chat-Kern + +- Online-Userliste via `userList` +- Chat-Screen +- `requestConversation` +- Textnachrichten senden/empfangen +- `messageSent`, `unreadChats` +- Reconnect und Offline-Banner + +### Phase 4: Suche, Inbox, Verlauf + +- Suchformular und `userSearch` +- Suchergebnisse +- Inbox mit `requestOpenConversations` +- Verlauf mit `requestHistory` +- Blockieren/Entblockieren + +### Phase 5: Bilder + +- Android Photo Picker +- Multipart Upload zu `/api/upload-image` +- Bildnachricht via Socket senden +- Bildanzeige ueber `/api/image/:code` +- Fehlerbehandlung fuer abgelaufene Bilder + +### Phase 6: Release-Haertung + +- ProGuard/R8-Regeln fuer Socket.IO/OkHttp testen +- Crash-/Error-Logging entscheiden +- Play Store Icons, Screenshots, App Name +- Datenschutz-/Impressum-/Regeln-Screens +- Test auf Emulator, echtem Android-Geraet, schlechtem Netz und App-Background + +## Risiken + +| Risiko | Auswirkung | Gegenmassnahme | +|---|---|---| +| Socket.IO Android Client Version passt nicht zum Server | Verbindungsfehler | Version gegen Socket.IO Server 4.x testen, `allowEIO3` ist serverseitig aktiv | +| Session-Cookie wird nicht zwischen REST und Socket geteilt | Login/Upload funktionieren inkonsistent | gemeinsame OkHttp CookieJar und expliziter `setSessionId` Flow | +| Apache WebSocket-Proxy ist instabil | Reconnects/Latenz | WebSocket-Regeln pruefen, Polling als Fallback | +| Backend speichert Chat nur im RAM | Nachrichten nach Server-Restart weg | fuer MVP akzeptieren, spaeter DB-Persistenz | +| App im Hintergrund verliert Socket | Nachrichten kommen nur bei geoeffneter App | fuer MVP akzeptieren, spaeter Push Notifications | +| Bild-Upload-Session-Fallback ist unscharf | falsche Zuordnung theoretisch moeglich | Backend vor Release eindeutiger machen | + +## Offene Entscheidungen + +- Soll Android zuerst als separates Repo oder als `android/` Ordner in diesem Repo entstehen? +- Soll der MVP nur Chat enthalten oder auch Feedback/Partner/FAQ? +- Soll das bestehende Design exakt nachgebaut oder fuer Mobile bewusst neu interpretiert werden? +- Soll die erste Version ohne Push Notifications starten? + +## Empfehlung Fuer Den Naechsten Schritt + +Ich wuerde als naechstes ein Android-Projekt im Ordner `android/` scaffolden und zuerst nur den technischen Durchstich bauen: + +1. `GET /api/session` +2. Socket.IO connect +3. `setSessionId` +4. Login +5. Empfang von `userList` +6. Senden und Empfangen einer Textnachricht + +Wenn dieser Durchstich steht, ist der groesste technische Unsicherheitsblock geloest. Danach ist der Rest vor allem UI- und Zustandsarbeit. diff --git a/android/DATA_SAFETY_DRAFT.md b/android/DATA_SAFETY_DRAFT.md new file mode 100644 index 0000000..64c41bf --- /dev/null +++ b/android/DATA_SAFETY_DRAFT.md @@ -0,0 +1,209 @@ +# Data Safety Entwurf + +Dies ist eine vorsichtige Arbeitsgrundlage für die Google-Play-Console auf Basis des aktuellen Android-Clients und des Node/Socket-Backends. + +Wichtig: + +- Das ist kein juristischer Bescheid, sondern eine technische Vorbewertung. +- Vor dem finalen Absenden in Play Console muss geprüft werden, ob Server, Reverse Proxy, Hosting oder Analytics weitere Daten verarbeiten. +- Wenn Drittanbieter-Dienste oder zusätzliche Logs aktiv sind, muss die Erklärung erweitert werden. + +## Technische Beobachtung aus dem aktuellen Projekt + +Die App überträgt oder verarbeitet aktuell mindestens: + +- Nickname +- Alter +- Geschlecht +- Land +- Chat-Nachrichten +- hochgeladene Bilder +- Feedback-Inhalte +- Session-/Cookie-Daten +- technische Verbindungsdaten im Rahmen von HTTP und Socket.IO + +Es gibt im aktuellen Android-Projekt keine Hinweise auf: + +- Werbe-SDKs +- In-App-Käufe +- Standortberechtigung +- Kontakte +- Telefonbuch +- Mikrofon +- Kamera-Zwang +- präzisen Standort + +## Konservative Erstbewertung für Google Play + +Empfehlung für die erste Ausfüllung: eher vorsichtig angeben, was tatsächlich serverseitig übertragen wird. + +### 1. Personal info + +Wahrscheinlich anzugeben: + +- `Name` + - Begründung: Der Nickname wird an das Backend übertragen und gegenüber anderen Nutzern angezeigt. +- `Other info` + - Begründung: Alter, Geschlecht und Land werden übertragen und im Chatkontext genutzt. + +### 2. Messages + +Wahrscheinlich anzugeben: + +- `Other in-app messages` + - Begründung: Chat-Nachrichten werden über Socket.IO übertragen und serverseitig verarbeitet. + +### 3. Photos and videos + +Wahrscheinlich anzugeben: + +- `Photos` + - Begründung: Bilder werden aktiv hochgeladen und anderen Nutzern im Chat zugänglich gemacht. + +### 4. App info and performance / Diagnostics + +Nur angeben, wenn tatsächlich entsprechende Logs dauerhaft erhoben, gespeichert oder ausgewertet werden. + +Aktueller Code zeigt: + +- Server-Logs in der Konsole +- Session-/Verbindungsprüfung + +Das reicht nicht automatisch für jede Play-Kategorie. Hier vorsichtig prüfen, was auf dem Hosting tatsächlich gespeichert wird. + +### 5. Device or other IDs + +Nicht vorschnell anhaken. + +Die Session-ID allein ist nicht automatisch gleichbedeutend mit einer dauerhaft erhobenen Geräte-ID im Sinne der Play-Kategorien. Nur dann angeben, wenn ihr eine solche ID gezielt speichert oder zur Profilbildung nutzt. + +## Vorschlag für die einzelnen Play-Fragen + +### Werden Daten erhoben? + +Voraussichtlich: + +- `Ja` + +### Werden Daten geteilt? + +Technisch nach aktuellem Stand wahrscheinlich: + +- `Nein`, sofern keine Weitergabe an Drittanbieter, Werbenetzwerke oder externe Analyseanbieter erfolgt + +Wichtig: + +- Wenn Webserver, CDN, Anti-Abuse-Dienste oder Hosting-Provider Daten in eigenem Namen auswerten, muss das separat geprüft werden. + +### Sind alle Daten verschlüsselt? + +Für Release vorgesehen: + +- `Ja`, wenn die produktive Android-App ausschließlich über HTTPS/WSS gegen `https://www.ypchat.net` läuft + +Wichtig: + +- Das setzt voraus, dass produktiv wirklich kein unverschlüsselter Verkehr verwendet wird. + +### Können Nutzer die Löschung ihrer Daten beantragen? + +Noch offen: + +- Das muss mit eurem tatsächlichen Prozess beantwortet werden. + +Wenn derzeit keine echte Löschfunktion oder kein dokumentierter Löschprozess existiert, dann nicht voreilig `Ja` angeben. + +## Arbeitsmatrix für die Play Console + +## Datentyp: Name + +- Empfehlung: `Collected` +- Shared: `No` +- Required or optional: `Optional` +- Purpose: + - `App functionality` + - eventuell `Developer communications`, falls Feedback-Antworten darüber organisiert würden + +Begründung: +- Nickname wird für Identifikation im Chat benötigt, ist aber vom Nutzer frei gewählt. + +## Datentyp: Other personal info + +- Empfehlung: `Collected` +- Shared: `No` +- Required or optional: `Optional` bis `Required` + +Empfehlung: +- eher `Required`, wenn Alter, Geschlecht und Land für die Nutzung zwingend eingegeben werden müssen + +- Purpose: + - `App functionality` + +Begründung: +- Diese Angaben sind Bestandteil des Chat-Profils und der Suche. + +## Datentyp: Messages + +- Empfehlung: `Collected` +- Shared: `No` +- Required or optional: `Required` +- Purpose: + - `App functionality` + +Begründung: +- Ohne Nachrichtenfunktion existiert die App inhaltlich nicht. + +## Datentyp: Photos + +- Empfehlung: `Collected` +- Shared: `No` +- Required or optional: `Optional` +- Purpose: + - `App functionality` + +Begründung: +- Bilder sind optional, aber Teil des Chat-Funktionsumfangs. + +## Datentyp: User-generated content + +Prüfen, ob zusätzlich anzugeben: + +- Feedback-Kommentare + +Je nach Auslegung kann das in Play Console zusätzlich als nutzergenerierter Inhalt relevant sein. Wenn die Konsole dafür eine passende Kategorie anbietet, ist `Collected`, `No shared`, `Optional`, `App functionality` eine plausible Einstufung. + +## Punkte, die vor dem finalen Absenden geklärt werden sollten + +1. Werden Chat-Nachrichten dauerhaft gespeichert oder nur teilweise? +2. Werden hochgeladene Bilder nur temporär gespeichert oder zusätzlich archiviert? +3. Werden Server-Logs mit IP-Adressen dauerhaft aufbewahrt? +4. Gibt es einen dokumentierten Löschprozess für Nutzeranfragen? +5. Gibt es irgendeine Form von Drittanbieter-Tracking oder Hosting-Auswertung? +6. Existiert bereits eine öffentliche Datenschutzerklärung mit genau diesen Punkten? + +## Empfehlung für die Play-Console-Erstbefüllung + +Mit heutigem Stand würde ich technisch von folgendem Startpunkt ausgehen: + +- Daten werden erhoben: `Ja` +- Daten werden geteilt: `Nein` +- Erhobene Kategorien: + - Name / Nickname + - sonstige persönliche Angaben + - Nachrichten + - Fotos + - ggf. nutzergenerierte Inhalte +- Zwecke: + - `App functionality` +- Verschlüsselung während der Übertragung: + - `Ja`, sofern Produktion ausschließlich HTTPS/WSS nutzt +- Löschanfrage: + - nur `Ja`, wenn ihr das tatsächlich organisatorisch abdecken könnt + +## Was noch fehlt + +Für einen wirklich sauberen Play-Store-Release fehlt noch mindestens: + +1. finale Datenschutzerklärung-URL +2. Entscheidung zur Store-Kategorie +3. finale Prüfung, ob Server-/Hosting-Logs zusätzliche Datenkategorien auslösen diff --git a/android/PLAY_CONSOLE_CHECKLIST.md b/android/PLAY_CONSOLE_CHECKLIST.md new file mode 100644 index 0000000..b49c45f --- /dev/null +++ b/android/PLAY_CONSOLE_CHECKLIST.md @@ -0,0 +1,64 @@ +# Play Console Checkliste + +Diese Liste ist als operative Vorlage für den ersten Eintrag in Google Play gedacht. + +## A. App anlegen + +- Entwicklerkonto geöffnet +- neue App angelegt +- Standardsprache gewählt +- App-Name eingetragen +- App oder Spiel ausgewählt +- kostenlose oder kostenpflichtige App festgelegt +- Kontakt-E-Mail hinterlegt + +## B. Store-Eintrag + +Aus [PLAY_STORE_LISTING.md](C:\Users\Torsten\OneDrive\Apps\singlechat\android\PLAY_STORE_LISTING.md) übernehmen: + +- App-Name +- Kurzbeschreibung DE +- Kurzbeschreibung EN +- Vollbeschreibung DE +- Vollbeschreibung EN + +Zusätzlich hochladen: + +- App-Symbol +- Smartphone-Screenshots +- Feature Graphic, falls gewünscht + +## C. App-Inhalte + +- Kategorie festgelegt +- Zielgruppe geprüft +- Werberichtlinien geprüft +- Data-Safety-Formular ausgefüllt +- Datenschutzerklärung-URL eingetragen: + - `https://www.ypchat.net/datenschutz` + +## D. Technische Veröffentlichung + +- `versionCode` geprüft +- `versionName` geprüft +- Release-Key vorhanden +- `android/key.properties` vorhanden +- `.aab` gebaut +- `.aab` in `Internal testing` hochgeladen + +## E. Test-Track + +- internen Test angelegt +- Tester hinzugefügt +- Release Notes eingetragen +- Installationslink geprüft + +## F. Vor Produktionsfreigabe + +- Login funktioniert +- Chat funktioniert +- Bildversand funktioniert +- Icon korrekt +- Datenschutz-Seite online erreichbar +- Impressum online erreichbar +- keine offensichtlichen UI-Fehler diff --git a/android/PLAY_STORE_LISTING.md b/android/PLAY_STORE_LISTING.md new file mode 100644 index 0000000..46a92bd --- /dev/null +++ b/android/PLAY_STORE_LISTING.md @@ -0,0 +1,120 @@ +# Play Store Texte + +Diese Vorschläge sind auf den aktuellen Funktionsumfang der Android-App abgestimmt: + +- Login mit Nickname, Alter, Geschlecht, Land +- internationalisierte Oberfläche +- Live-Chat per Socket-Verbindung +- Bildaustausch +- Blockieren / Entsperren +- Feedback-Bereich + +## App-Name + +Empfehlung: + +- `YPChat - Single Chat` + +Alternative: + +- `SingleChat by YPChat` +- `YPChat` + +## Kurzbeschreibung + +### Deutsch + +Empfehlung: + +`International chatten, Bilder teilen und neue Kontakte direkt per Live-Chat finden.` + +Alternative: + +`Direkter Single-Chat mit Bildaustausch, Länderwahl und schneller Live-Verbindung.` + +### Englisch + +Recommended: + +`Chat worldwide, share images and meet new people instantly in live private chat.` + +Alternative: + +`Fast live chat with image sharing, country selection and direct private messaging.` + +## Vollständige Beschreibung + +### Deutsch + +`YPChat ist eine schnelle und direkte Chat-App für Menschen, die unkompliziert neue Kontakte knüpfen möchten. Du erstellst in wenigen Sekunden dein Profil mit Nickname, Alter, Geschlecht und Land und bist sofort im Live-Chat aktiv. + +Die App verbindet sich in Echtzeit mit dem Chat-Backend und zeigt dir online verfügbare Nutzer, Suchergebnisse, offene Unterhaltungen und deinen Verlauf. Gespräche laufen direkt und ohne Umwege. Zusätzlich kannst du Bilder senden und empfangen, um Unterhaltungen persönlicher zu machen. + +Zu den wichtigsten Funktionen gehören: +- Live-Chat mit direkter Socket-Verbindung +- internationale Nutzung mit lokalisierter Oberfläche +- Länder- und Geschlechtsauswahl beim Einstieg +- Suche nach passenden Kontakten +- Posteingang und Verlauf +- Bildaustausch im Chat +- Nutzer blockieren und wieder entsperren +- Feedback-Bereich für Hinweise und Verbesserungsvorschläge + +YPChat legt Wert auf einfache Bedienung, schnelle Reaktion und einen kompakten Einstieg ohne unnötige Schritte. Wenn du spontan chatten, neue Menschen kennenlernen und Inhalte direkt austauschen möchtest, bietet dir die App einen klaren und schnellen Zugang.` + +### Englisch + +`YPChat is a fast and direct chat app for people who want to meet new contacts without unnecessary steps. You create a profile in seconds with nickname, age, gender and country, then enter live chat immediately. + +The app connects to the chat backend in real time and shows online users, search results, open conversations and chat history. Conversations are direct and lightweight. You can also send and receive images to make chats more personal. + +Main features include: +- live chat with real-time socket connection +- international usage with a localized interface +- country and gender selection during onboarding +- search for matching contacts +- inbox and history +- image sharing in chat +- block and unblock users +- feedback area for reports and suggestions + +YPChat focuses on simple interaction, fast response and quick entry into conversation. If you want to chat spontaneously, meet new people and exchange content directly, the app gives you a clear and fast experience.` + +## Keywords / Suchbegriffe + +Nicht direkt als Google-Play-Feld vorhanden, aber hilfreich für interne Abstimmung: + +- Single Chat +- Live Chat +- anonymer Chat +- privater Chat +- international chat +- Bildaustausch +- neue Leute kennenlernen +- online chat + +## Screenshots-Empfehlung + +Für den ersten Store-Eintrag empfehle ich mindestens diese Smartphone-Screens: + +1. Login mit Länder- und Geschlechtsauswahl +2. Startseite / Online-Liste +3. Chatansicht mit echter Unterhaltung +4. Suchansicht +5. Mehr-Bereich mit Feedback / FAQ / Sicherheit + +## Offene Punkte vor Store-Eintrag + +Diese Punkte sind noch nicht in den Texten aufgelöst und müssen von dir final bestätigt werden: + +1. Soll der öffentliche Markenname im Store `YPChat` oder `SingleChat` sein? +2. Welche URL wird als Datenschutzerklärung verwendet? +3. Soll die App als `Social`, `Dating` oder `Communication` eingeordnet werden? + +## Empfehlung + +Wenn du schnell live gehen willst, würde ich für den ersten Store-Eintrag Folgendes verwenden: + +- App-Name: `YPChat - Single Chat` +- Kurzbeschreibung DE: `International chatten, Bilder teilen und neue Kontakte direkt per Live-Chat finden.` +- Kurzbeschreibung EN: `Chat worldwide, share images and meet new people instantly in live private chat.` diff --git a/android/PUBLISHING.md b/android/PUBLISHING.md new file mode 100644 index 0000000..834564c --- /dev/null +++ b/android/PUBLISHING.md @@ -0,0 +1,161 @@ +# Android-Veröffentlichung + +Diese App wird für Google Play als `Android App Bundle` (`.aab`) veröffentlicht. + +## Stand des Projekts + +- `applicationId`: `net.ypchat.app` +- `minSdk`: `26` +- `targetSdk`: `36` +- Produktiv-Backend standardmäßig: `https://www.ypchat.net` +- Release-Build verwendet standardmäßig `HTTPS only` + +Hinweis: Laut Android Developers müssen neue Apps bei Google Play seit dem 31. August 2025 mindestens auf API-Level 35 zielen. Dieses Projekt erfüllt das bereits mit `targetSdk = 36`. + +Quelle: +- [Meet Google Play's target API level requirement](https://developer.android.com/distribute/best-practices/develop/target-sdk) +- [Use Play App Signing](https://support.google.com/googleplay/android-developer/answer/9842756) +- [Provide information for Google Play's Data safety section](https://support.google.com/googleplay/android-developer/answer/10787469) + +## 1. Upload-Key erzeugen + +Im Ordner `android/` einen Upload-Key anlegen, zum Beispiel: + +```powershell +keytool -genkeypair ` + -v ` + -keystore release-upload-key.jks ` + -alias upload ` + -keyalg RSA ` + -keysize 4096 ` + -validity 10000 +``` + +Danach `android/key.properties` aus `android/key.properties.example` erzeugen: + +```properties +storeFile=release-upload-key.jks +storePassword=DEIN_STORE_PASSWORT +keyAlias=upload +keyPassword=DEIN_KEY_PASSWORT +``` + +Wichtig: +- `android/key.properties` +- `android/*.jks` +- `android/*.keystore` + +sind in `.gitignore` ausgenommen und sollen nicht committed werden. + +## 2. Versionsnummer vor Release setzen + +Datei: +- `android/app/build.gradle.kts` + +Vor jedem Store-Release anpassen: + +```kotlin +versionCode = 2 +versionName = "0.2.0" +``` + +Regel: +- `versionCode` muss bei jedem Play-Update höher sein als beim letzten Upload. +- `versionName` ist die sichtbare Versionsnummer im Store. + +## 3. Release-Bundle bauen + +Im Ordner `android/`: + +```powershell +.\gradlew bundleRelease +``` + +Das Ergebnis liegt hier: + +```text +android/app/build/outputs/bundle/release/app-release.aab +``` + +Falls zuerst ein Clean-Build gewünscht ist: + +```powershell +.\gradlew clean bundleRelease +``` + +## 4. Google Play Console + +Für eine neue App in der Play Console: + +1. App anlegen +2. Standardsprache wählen +3. App-Name festlegen +4. App-Kategorie auswählen +5. Kontakt-E-Mail hinterlegen + +Danach: + +1. `Internal testing` oder `Closed testing` anlegen +2. `app-release.aab` hochladen +3. Play App Signing aktivieren + +Empfehlung: +- Für die erste Veröffentlichung zuerst `Internal testing` +- Danach `Closed testing` +- Erst anschließend `Production` + +## 5. Store-Inhalte vorbereiten + +Benötigt werden in der Regel: + +- App-Name +- Kurzbeschreibung +- Vollständige Beschreibung +- App-Symbol +- Screenshots vom Smartphone +- Datenschutzerklärung-URL +- Support-/Kontaktadresse + +Für diese App zusätzlich sinnvoll: + +- Hinweis auf Chat, Bildaustausch und internationale Nutzung +- Hinweis auf Moderation/Blockieren/Feedback + +## 6. Data Safety + +In der Play Console muss das Formular zur Datensicherheit ausgefüllt werden. + +Nach aktuellem Projektstand werden mindestens diese Daten serverseitig verarbeitet oder übertragen: + +- Nutzername +- Alter +- Geschlecht +- Land +- Chat-Nachrichten +- Bilder +- Feedback-Inhalte +- Session-/Verbindungsdaten + +Das Formular muss anhand des tatsächlichen Serververhaltens final geprüft werden. Wenn Backend oder Logs zusätzliche Daten erfassen, muss das dort ebenfalls deklariert werden. + +## 7. Vor dem ersten echten Release prüfen + +- Release-Build startet auf echtem Gerät +- Login gegen Produktiv-Backend funktioniert +- Socket-Verbindung stabil +- Bilder senden/empfangen funktioniert +- Länderauswahl/Geschlecht korrekt +- App-Symbol korrekt +- Texte/Internationalisierung korrekt +- Impressum und Datenschutzerklärung vorhanden +- Datenschutzerklärung-URL öffentlich erreichbar + +## 8. Empfehlung für den nächsten Schritt + +Der sinnvollste nächste operative Schritt ist: + +1. Upload-Key erzeugen +2. `android/key.properties` lokal anlegen +3. `versionCode` und `versionName` setzen +4. `bundleRelease` bauen +5. mit dem erzeugten `.aab` in `Internal testing` hochladen diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..750ce5e --- /dev/null +++ b/android/README.md @@ -0,0 +1,71 @@ +# YPChat Android + +Native Android-App fuer den bestehenden SingleChat/YPChat-Server. + +## Stack + +- Kotlin +- Jetpack Compose +- Retrofit/OkHttp fuer REST +- Socket.IO Android Client fuer den Chat +- persistente OkHttp `CookieJar`, damit REST und Socket dieselbe `connect.sid`-Session nutzen + +## Projekt Oeffnen + +Den Ordner `android/` in Android Studio oeffnen. Das Projekt ist auf Android Gradle Plugin `8.13.2`, Gradle `8.13`, JDK 17 und `compileSdk = 36` ausgelegt. + +Falls Android Studio keinen Gradle Wrapper erzeugt, kann er in diesem Ordner mit einer lokalen Gradle-Installation nachgezogen werden: + +```powershell +gradle wrapper --gradle-version 8.13 +``` + +Danach: + +```powershell +.\gradlew.bat :app:assembleDebug +``` + +## Backend-URLs + +Debug und Release verwenden standardmaessig: + +```text +https://www.ypchat.net +``` + +Fuer lokale Tests kann die URL in `android/local.properties` ueberschrieben werden: + +```properties +ypchat.baseUrl=http://10.0.2.2:3300 +``` + +`10.0.2.2` zeigt im Android Emulator auf den lokalen Rechner. Auf einem echten Geraet muss stattdessen die LAN-IP des Rechners oder ein Dev-Tunnel verwendet werden, z.B.: + +```properties +ypchat.baseUrl=http://192.168.178.42:3300 +``` + +## Implementierter Durchstich + +- `GET /api/session` +- Socket.IO Connect mit WebSocket zuerst und Polling-Fallback +- `setSessionId` +- `login` +- `userList` +- `requestConversation` +- Textnachrichten ueber `message` +- `requestOpenConversations` +- `requestHistory` +- `userSearch` +- `blockUser` +- `POST /api/logout` +- Laenderliste im Login ueber `/api/countries` +- Upload-Client fuer `/api/upload-image` im Repository vorbereitet + +## Noch Offen + +- Android Photo Picker an `uploadImage()` anschliessen +- lokale Logout-Markierung analog Web-Frontend speichern +- Reconnect-Feinschliff nach App-Background testen +- Play-Store-Texte, Datenschutz, Impressum und App-Icons finalisieren diff --git a/android/RELEASE_UPLOAD_CHECKLIST.md b/android/RELEASE_UPLOAD_CHECKLIST.md new file mode 100644 index 0000000..e5370ab --- /dev/null +++ b/android/RELEASE_UPLOAD_CHECKLIST.md @@ -0,0 +1,50 @@ +# Release Upload Checkliste + +## Vor dem Build + +- Produktiv-Backend ist korrekt +- `versionCode` ist höher als beim letzten Upload +- `versionName` ist gesetzt +- `android/key.properties` ist lokal vorhanden +- Upload-Key funktioniert + +## Release-Build + +Im Ordner `android/`: + +```powershell +.\gradlew bundleRelease +``` + +## Erwartetes Artefakt + +```text +android/app/build/outputs/bundle/release/app-release.aab +``` + +## Vor Upload kurz prüfen + +- App startet auf echtem Gerät +- Login gegen Produktion funktioniert +- Online-Liste lädt +- Chat sendet und empfängt +- Bilder senden und empfangen funktioniert +- Länderauswahl vorhanden +- Geschlechtsauswahl vorhanden +- Datenschutz unter `https://www.ypchat.net/datenschutz` erreichbar +- App-Symbol korrekt + +## Upload in Play Console + +1. `Internal testing` +2. neues Release anlegen +3. `.aab` hochladen +4. Release Notes ergänzen +5. speichern +6. an Tester ausrollen + +## Nach Upload + +- Installationslink testen +- App auf mindestens einem echten Gerät aus dem Test-Track installieren +- Upgrade-Fähigkeit für spätere Versionen im Blick behalten diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..63f88b2 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,118 @@ +import java.util.Properties +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") +} + +val localProperties = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use(::load) + } +} + +val keyProperties = Properties().apply { + val file = rootProject.file("key.properties") + if (file.exists()) { + file.inputStream().use(::load) + } +} + +val releaseStoreFile = keyProperties.getProperty("storeFile")?.let { rootProject.file(it) } + +val defaultBaseUrl = "https://www.ypchat.net" +val appBaseUrl = localProperties.getProperty("ypchat.baseUrl", defaultBaseUrl) +val hasReleaseSigning = releaseStoreFile?.exists() == true + +android { + namespace = "net.ypchat.app" + compileSdk = 36 + + defaultConfig { + applicationId = "net.ypchat.app" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + } + + lint { + checkReleaseBuilds = false + abortOnError = false + } + + signingConfigs { + if (hasReleaseSigning) { + create("release") { + storeFile = releaseStoreFile + storePassword = keyProperties.getProperty("storePassword") + keyAlias = keyProperties.getProperty("keyAlias") + keyPassword = keyProperties.getProperty("keyPassword") + } + } + } + + buildTypes { + debug { + buildConfigField("String", "BASE_URL", "\"$appBaseUrl\"") + manifestPlaceholders["usesCleartextTraffic"] = true + } + release { + buildConfigField("String", "BASE_URL", "\"$appBaseUrl\"") + isMinifyEnabled = true + isShrinkResources = true + manifestPlaceholders["usesCleartextTraffic"] = false + if (hasReleaseSigning) { + signingConfig = signingConfigs.getByName("release") + } + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2026.03.01") + + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") + + implementation("com.squareup.okhttp3:okhttp:5.3.2") + implementation("com.squareup.retrofit2:retrofit:3.0.0") + implementation("com.squareup.retrofit2:converter-gson:3.0.0") + implementation("io.socket:socket.io-client:2.1.2") { + exclude(group = "org.json", module = "json") + } + implementation("io.coil-kt.coil3:coil-compose:3.4.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") + + debugImplementation("androidx.compose.ui:ui-tooling") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..8209826 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,6 @@ +# Keep Socket.IO and Engine.IO callback classes reachable in release builds. +-keep class io.socket.** { *; } +-keep class io.socket.engineio.** { *; } +-keep class okhttp3.** { *; } +-dontwarn io.socket.** +-dontwarn okhttp3.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e0c2c5d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/java/net/ypchat/app/MainActivity.kt b/android/app/src/main/java/net/ypchat/app/MainActivity.kt new file mode 100644 index 0000000..d0c51c5 --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/MainActivity.kt @@ -0,0 +1,20 @@ +package net.ypchat.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import net.ypchat.app.ui.ChatViewModel +import net.ypchat.app.ui.ChatViewModelFactory +import net.ypchat.app.ui.YpChatRoot + +class MainActivity : ComponentActivity() { + private val viewModel: ChatViewModel by viewModels { + ChatViewModelFactory((application as YpChatApp).container.chatRepository) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { YpChatRoot(viewModel) } + } +} diff --git a/android/app/src/main/java/net/ypchat/app/YpChatApp.kt b/android/app/src/main/java/net/ypchat/app/YpChatApp.kt new file mode 100644 index 0000000..40288b2 --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/YpChatApp.kt @@ -0,0 +1,14 @@ +package net.ypchat.app + +import android.app.Application +import net.ypchat.app.core.AppContainer + +class YpChatApp : Application() { + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + container = AppContainer(this) + } +} diff --git a/android/app/src/main/java/net/ypchat/app/core/AppConfig.kt b/android/app/src/main/java/net/ypchat/app/core/AppConfig.kt new file mode 100644 index 0000000..aacc48a --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/core/AppConfig.kt @@ -0,0 +1,7 @@ +package net.ypchat.app.core + +import net.ypchat.app.BuildConfig + +object AppConfig { + val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/') +} diff --git a/android/app/src/main/java/net/ypchat/app/core/AppContainer.kt b/android/app/src/main/java/net/ypchat/app/core/AppContainer.kt new file mode 100644 index 0000000..eb85f3f --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/core/AppContainer.kt @@ -0,0 +1,32 @@ +package net.ypchat.app.core + +import android.content.Context +import net.ypchat.app.data.api.RestApi +import net.ypchat.app.data.api.SocketClient +import net.ypchat.app.data.repository.ChatRepository +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +class AppContainer(context: Context) { + val cookieJar = SessionCookieJar(context) + val profileStore = ProfileStore(context) + + val okHttpClient: OkHttpClient = OkHttpClient.Builder() + .cookieJar(cookieJar) + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(AppConfig.baseUrl + "/") + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + val restApi: RestApi = retrofit.create(RestApi::class.java) + val socketClient = SocketClient(AppConfig.baseUrl, okHttpClient) + val chatRepository = ChatRepository(restApi, socketClient, cookieJar, profileStore) +} diff --git a/android/app/src/main/java/net/ypchat/app/core/ProfileStore.kt b/android/app/src/main/java/net/ypchat/app/core/ProfileStore.kt new file mode 100644 index 0000000..643bbef --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/core/ProfileStore.kt @@ -0,0 +1,41 @@ +package net.ypchat.app.core + +import android.content.Context + +data class SavedProfile( + val nickname: String = "", + val gender: String = "", + val age: Int = 18, + val country: String = "Germany" +) + +class ProfileStore(context: Context) { + private val preferences = context.getSharedPreferences("ypchat_profile", Context.MODE_PRIVATE) + + fun read(): SavedProfile = SavedProfile( + nickname = preferences.getString(KEY_NICKNAME, "").orEmpty(), + gender = preferences.getString(KEY_GENDER, "").orEmpty(), + age = preferences.getInt(KEY_AGE, 18), + country = preferences.getString(KEY_COUNTRY, "Germany").orEmpty() + ) + + fun write(profile: SavedProfile) { + preferences.edit() + .putString(KEY_NICKNAME, profile.nickname.trim()) + .putString(KEY_GENDER, profile.gender) + .putInt(KEY_AGE, profile.age) + .putString(KEY_COUNTRY, profile.country) + .apply() + } + + fun clear() { + preferences.edit().clear().apply() + } + + private companion object { + const val KEY_NICKNAME = "nickname" + const val KEY_GENDER = "gender" + const val KEY_AGE = "age" + const val KEY_COUNTRY = "country" + } +} diff --git a/android/app/src/main/java/net/ypchat/app/core/SessionCookieJar.kt b/android/app/src/main/java/net/ypchat/app/core/SessionCookieJar.kt new file mode 100644 index 0000000..d253a30 --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/core/SessionCookieJar.kt @@ -0,0 +1,29 @@ +package net.ypchat.app.core + +import android.content.Context +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +class SessionCookieJar(context: Context) : CookieJar { + private val prefs = context.getSharedPreferences("ypchat_cookies", Context.MODE_PRIVATE) + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val editor = prefs.edit() + cookies.forEach { cookie -> + editor.putString(cookie.name, cookie.toString()) + } + editor.apply() + } + + override fun loadForRequest(url: HttpUrl): List { + return prefs.all.values + .mapNotNull { it as? String } + .mapNotNull { Cookie.parse(url, it) } + .filter { cookie -> cookie.expiresAt > System.currentTimeMillis() } + } + + fun clear() { + prefs.edit().clear().apply() + } +} diff --git a/android/app/src/main/java/net/ypchat/app/data/api/RestApi.kt b/android/app/src/main/java/net/ypchat/app/data/api/RestApi.kt new file mode 100644 index 0000000..65983f7 --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/data/api/RestApi.kt @@ -0,0 +1,54 @@ +package net.ypchat.app.data.api + +import net.ypchat.app.data.model.CountriesResponse +import net.ypchat.app.data.model.FeedbackAdminLoginRequest +import net.ypchat.app.data.model.FeedbackAdminStatusResponse +import net.ypchat.app.data.model.FeedbackRequest +import net.ypchat.app.data.model.FeedbackResponse +import net.ypchat.app.data.model.ImageUploadResponse +import net.ypchat.app.data.model.LogoutResponse +import net.ypchat.app.data.model.PartnerLinkDto +import net.ypchat.app.data.model.SessionResponse +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part + +interface RestApi { + @GET("api/session") + suspend fun session(): SessionResponse + + @POST("api/logout") + suspend fun logout(): LogoutResponse + + @GET("api/countries") + suspend fun countries(): CountriesResponse + + @GET("api/feedback") + suspend fun feedback(): FeedbackResponse + + @GET("api/feedback/admin-status") + suspend fun feedbackAdminStatus(): FeedbackAdminStatusResponse + + @POST("api/feedback") + suspend fun submitFeedback(@Body request: FeedbackRequest) + + @POST("api/feedback/admin-login") + suspend fun feedbackAdminLogin(@Body request: FeedbackAdminLoginRequest): FeedbackAdminStatusResponse + + @POST("api/feedback/admin-logout") + suspend fun feedbackAdminLogout() + + @retrofit2.http.DELETE("api/feedback/{id}") + suspend fun deleteFeedback(@retrofit2.http.Path("id") id: String) + + @GET("api/partners") + suspend fun partners(): List + + @Multipart + @POST("api/upload-image") + suspend fun uploadImage(@Part image: MultipartBody.Part): Response +} diff --git a/android/app/src/main/java/net/ypchat/app/data/api/SocketClient.kt b/android/app/src/main/java/net/ypchat/app/data/api/SocketClient.kt new file mode 100644 index 0000000..94d4a65 --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/data/api/SocketClient.kt @@ -0,0 +1,269 @@ +package net.ypchat.app.data.api + +import io.socket.client.IO +import io.socket.client.Socket +import io.socket.engineio.client.transports.Polling +import io.socket.engineio.client.transports.WebSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import net.ypchat.app.data.model.ChatMessageDto +import net.ypchat.app.data.model.HistoryItemDto +import net.ypchat.app.data.model.InboxItemDto +import net.ypchat.app.data.model.SocketEvent +import net.ypchat.app.data.model.UserDto +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +class SocketClient( + private val baseUrl: String, + private val okHttpClient: OkHttpClient +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events + + private var socket: Socket? = null + private var pendingExpressSessionId: String? = null + + val isConnected: Boolean + get() = socket?.connected() == true + + fun connect() { + disconnect() + + val options = IO.Options().apply { + transports = arrayOf(WebSocket.NAME, Polling.NAME) + reconnection = true + reconnectionAttempts = Int.MAX_VALUE + reconnectionDelay = 1_000 + timeout = 10_000 + callFactory = okHttpClient + webSocketFactory = okHttpClient + } + + socket = IO.socket(baseUrl, options).also { s -> + s.on(Socket.EVENT_CONNECT) { + pendingExpressSessionId?.let { sessionId -> + s.emit("setSessionId", JSONObject().put("expressSessionId", sessionId)) + } + emit(SocketEvent.ConnectionChanged(true)) + } + s.on(Socket.EVENT_DISCONNECT) { args -> + emit(SocketEvent.ConnectionChanged(false, args.firstOrNull()?.toString())) + } + s.on(Socket.EVENT_CONNECT_ERROR) { args -> + emit(SocketEvent.Error("Socket-Verbindung fehlgeschlagen: ${args.firstOrNull()?.toString().orEmpty()}")) + } + + s.on("connected") { args -> + args.firstJson()?.let { json -> + emit( + SocketEvent.Connected( + sessionId = json.optStringOrNull("sessionId"), + loggedIn = json.optBoolean("loggedIn", false), + user = json.optJSONObject("user")?.toUserDto() + ) + ) + } + } + s.on("loginSuccess") { args -> + args.firstJson()?.let { json -> + emit(SocketEvent.LoginSuccess(json.optStringOrNull("sessionId"), json.optJSONObject("user")?.toUserDto())) + } + } + s.on("userList") { args -> + args.firstJson()?.optJSONArray("users")?.let { users -> + emit(SocketEvent.UserList(users.toObjectList { it.toUserDto() })) + } + } + s.on("message") { args -> + args.firstJson()?.let { emit(SocketEvent.IncomingMessage(it.toMessageDto())) } + } + s.on("messageSent") { args -> + args.firstJson()?.let { json -> + emit(SocketEvent.MessageSent(json.optStringOrNull("messageId"), json.optStringOrNull("to"))) + } + } + s.on("conversation") { args -> + args.firstJson()?.let { json -> + val messages = json.optJSONArray("messages")?.toObjectList { it.toMessageDto() }.orEmpty() + emit(SocketEvent.Conversation(json.optString("with"), messages)) + } + } + s.on("searchResults") { args -> + args.firstJson()?.optJSONArray("results")?.let { results -> + emit(SocketEvent.SearchResults(results.toObjectList { it.toUserDto() })) + } + } + s.on("historyResults") { args -> + args.firstJson()?.optJSONArray("results")?.let { results -> + emit(SocketEvent.HistoryResults(results.toObjectList { it.toHistoryItemDto() })) + } + } + s.on("inboxResults") { args -> + args.firstJson()?.optJSONArray("results")?.let { results -> + emit(SocketEvent.InboxResults(results.toObjectList { it.toInboxItemDto() })) + } + } + s.on("unreadChats") { args -> + args.firstJson()?.let { emit(SocketEvent.UnreadChats(it.optInt("count", 0))) } + } + s.on("userBlocked") { args -> + args.firstJson()?.let { emit(SocketEvent.UserBlocked(it.optString("userName"))) } + } + s.on("userUnblocked") { args -> + args.firstJson()?.let { emit(SocketEvent.UserUnblocked(it.optString("userName"))) } + } + s.on("commandResult") { args -> + args.firstJson()?.let { json -> + emit(SocketEvent.CommandResult(json.optJSONArray("lines").toStringList(), json.optString("kind", "info"))) + } + } + s.on("commandTable") { args -> + args.firstJson()?.let { json -> + emit( + SocketEvent.CommandTable( + title = json.optString("title", "Ausgabe"), + columns = json.optJSONArray("columns").toStringList(), + rows = json.optJSONArray("rows").toNestedStringList() + ) + ) + } + } + s.on("error") { args -> + val message = args.firstJson()?.optString("message") ?: args.firstOrNull()?.toString() ?: "Unbekannter Socket-Fehler" + emit(SocketEvent.Error(message)) + } + s.connect() + } + } + + fun disconnect() { + socket?.disconnect() + socket?.off() + socket = null + } + + fun setSessionId(expressSessionId: String) { + pendingExpressSessionId = expressSessionId + socket?.takeIf { it.connected() }?.emit("setSessionId", JSONObject().put("expressSessionId", expressSessionId)) + } + + fun login(userName: String, gender: String, age: Int, country: String, expressSessionId: String?) { + socket?.emit( + "login", + JSONObject() + .put("userName", userName) + .put("gender", gender) + .put("age", age) + .put("country", country) + .put("expressSessionId", expressSessionId) + ) + } + + fun sendMessage(toUserName: String?, message: String, messageId: String = System.currentTimeMillis().toString()) { + val payload = JSONObject() + .put("message", message.trim()) + .put("messageId", messageId) + + if (!toUserName.isNullOrBlank()) { + payload.put("toUserName", toUserName) + } + + socket?.emit("message", payload) + } + + fun sendImage(toUserName: String, imageCode: String, imageUrl: String, messageId: String = System.currentTimeMillis().toString()) { + socket?.emit( + "message", + JSONObject() + .put("toUserName", toUserName) + .put("message", imageCode) + .put("messageId", messageId) + .put("isImage", true) + .put("imageUrl", imageUrl) + ) + } + + fun requestConversation(withUserName: String) { + socket?.emit("requestConversation", JSONObject().put("withUserName", withUserName)) + } + + fun userSearch(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: List, genders: List) { + socket?.emit( + "userSearch", + JSONObject() + .put("nameIncludes", nameIncludes) + .put("minAge", minAge) + .put("maxAge", maxAge) + .put("countries", JSONArray(countries)) + .put("genders", JSONArray(genders)) + ) + } + + fun requestHistory() = socket?.emit("requestHistory") + fun requestOpenConversations() = socket?.emit("requestOpenConversations") + fun blockUser(userName: String) = socket?.emit("blockUser", JSONObject().put("userName", userName)) + fun unblockUser(userName: String) = socket?.emit("unblockUser", JSONObject().put("userName", userName)) + + private fun emit(event: SocketEvent) { + scope.launch { _events.emit(event) } + } +} + +private fun Array.firstJson(): JSONObject? = firstOrNull() as? JSONObject + +private fun JSONObject.optStringOrNull(name: String): String? = if (has(name) && !isNull(name)) optString(name) else null + +private fun JSONObject.toUserDto(): UserDto = UserDto( + sessionId = optStringOrNull("sessionId"), + userName = optString("userName"), + gender = optString("gender"), + age = optInt("age", 0), + country = optString("country"), + isoCountryCode = optString("isoCountryCode") +) + +private fun JSONObject.toMessageDto(): ChatMessageDto = ChatMessageDto( + from = optString("from"), + to = optStringOrNull("to"), + message = optString("message"), + messageId = optStringOrNull("messageId"), + timestamp = optString("timestamp"), + read = optBoolean("read", false), + isImage = optBoolean("isImage", false), + imageType = optStringOrNull("imageType"), + imageUrl = optStringOrNull("imageUrl"), + imageCode = optStringOrNull("imageCode") +) + +private fun JSONObject.toHistoryItemDto(): HistoryItemDto = HistoryItemDto( + userName = optString("userName"), + lastMessage = optJSONObject("lastMessage")?.toMessageDto() +) + +private fun JSONObject.toInboxItemDto(): InboxItemDto = InboxItemDto( + userName = optString("userName"), + unreadCount = optInt("unreadCount", 0) +) + +private fun JSONArray?.toStringList(): List { + if (this == null) return emptyList() + return List(length()) { index -> opt(index)?.toString().orEmpty() } +} + +private fun JSONArray?.toNestedStringList(): List> { + if (this == null) return emptyList() + return List(length()) { index -> optJSONArray(index).toStringList() } +} + +private fun JSONArray.toObjectList(mapper: (JSONObject) -> T): List = buildList { + for (index in 0 until length()) { + optJSONObject(index)?.let { add(mapper(it)) } + } +} diff --git a/android/app/src/main/java/net/ypchat/app/data/model/Models.kt b/android/app/src/main/java/net/ypchat/app/data/model/Models.kt new file mode 100644 index 0000000..2c6304f --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/data/model/Models.kt @@ -0,0 +1,99 @@ +package net.ypchat.app.data.model + +import com.google.gson.annotations.SerializedName + +data class UserDto( + val sessionId: String? = null, + val userName: String = "", + val gender: String = "", + val age: Int = 0, + val country: String = "", + val isoCountryCode: String = "" +) + +data class ChatMessageDto( + val from: String = "", + val to: String? = null, + val message: String = "", + val messageId: String? = null, + val timestamp: String = "", + val read: Boolean = false, + val isImage: Boolean = false, + val imageType: String? = null, + val imageUrl: String? = null, + val imageCode: String? = null +) + +data class HistoryItemDto( + val userName: String = "", + val lastMessage: ChatMessageDto? = null +) + +data class InboxItemDto( + val userName: String = "", + val unreadCount: Int = 0 +) + +data class CountryOption( + val englishName: String, + val displayName: String, + val isoCode: String +) + +data class SessionResponse( + val loggedIn: Boolean = false, + val sessionId: String? = null, + val user: UserDto? = null +) + +data class LogoutResponse( + val success: Boolean = false +) + +data class ImageUploadResponse( + val success: Boolean = false, + val code: String? = null, + val url: String? = null, + val error: String? = null +) + +data class FeedbackItemDto( + val id: String = "", + val name: String? = null, + val age: Int? = null, + val country: String? = null, + val gender: String? = null, + val comment: String = "", + val createdAt: String = "" +) + +data class FeedbackResponse( + val items: List = emptyList(), + val admin: Boolean = false +) + +data class FeedbackAdminStatusResponse( + val authenticated: Boolean = false, + val username: String? = null +) + +data class FeedbackRequest( + val name: String = "", + val age: Int? = null, + val country: String = "", + val gender: String = "", + val comment: String = "" +) + +data class FeedbackAdminLoginRequest( + val username: String = "", + val password: String = "" +) + +data class PartnerLinkDto( + @SerializedName("Page Name") + val pageName: String = "", + val url: String = "" +) + +class CountriesResponse : LinkedHashMap() diff --git a/android/app/src/main/java/net/ypchat/app/data/model/SocketEvent.kt b/android/app/src/main/java/net/ypchat/app/data/model/SocketEvent.kt new file mode 100644 index 0000000..ceda31b --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/data/model/SocketEvent.kt @@ -0,0 +1,20 @@ +package net.ypchat.app.data.model + +sealed interface SocketEvent { + data class Connected(val sessionId: String?, val loggedIn: Boolean, val user: UserDto?) : SocketEvent + data class LoginSuccess(val sessionId: String?, val user: UserDto?) : SocketEvent + data class UserList(val users: List) : SocketEvent + data class IncomingMessage(val message: ChatMessageDto) : SocketEvent + data class MessageSent(val messageId: String?, val to: String?) : SocketEvent + data class Conversation(val withUserName: String, val messages: List) : SocketEvent + data class SearchResults(val results: List) : SocketEvent + data class HistoryResults(val results: List) : SocketEvent + data class InboxResults(val results: List) : SocketEvent + data class UnreadChats(val count: Int) : SocketEvent + data class UserBlocked(val userName: String) : SocketEvent + data class UserUnblocked(val userName: String) : SocketEvent + data class CommandResult(val lines: List, val kind: String) : SocketEvent + data class CommandTable(val title: String, val columns: List, val rows: List>) : SocketEvent + data class Error(val message: String) : SocketEvent + data class ConnectionChanged(val connected: Boolean, val reason: String? = null) : SocketEvent +} diff --git a/android/app/src/main/java/net/ypchat/app/data/repository/ChatRepository.kt b/android/app/src/main/java/net/ypchat/app/data/repository/ChatRepository.kt new file mode 100644 index 0000000..faa0922 --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/data/repository/ChatRepository.kt @@ -0,0 +1,408 @@ +package net.ypchat.app.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import net.ypchat.app.core.AppConfig +import net.ypchat.app.core.ProfileStore +import net.ypchat.app.core.SavedProfile +import net.ypchat.app.core.SessionCookieJar +import net.ypchat.app.data.api.RestApi +import net.ypchat.app.data.api.SocketClient +import net.ypchat.app.data.model.ChatMessageDto +import net.ypchat.app.data.model.CountryOption +import net.ypchat.app.data.model.FeedbackAdminLoginRequest +import net.ypchat.app.data.model.FeedbackItemDto +import net.ypchat.app.data.model.FeedbackRequest +import net.ypchat.app.data.model.HistoryItemDto +import net.ypchat.app.data.model.InboxItemDto +import net.ypchat.app.data.model.PartnerLinkDto +import net.ypchat.app.data.model.SocketEvent +import net.ypchat.app.data.model.UserDto +import okhttp3.MultipartBody +import java.util.Locale + +class ChatRepository( + private val restApi: RestApi, + private val socketClient: SocketClient, + private val cookieJar: SessionCookieJar, + private val profileStore: ProfileStore +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val _state = MutableStateFlow(ChatState()) + val state: StateFlow = _state.asStateFlow() + private var timeoutTickerStarted = false + + init { + scope.launch { + socketClient.events.collect { event -> reduce(event) } + } + startTimeoutTicker() + } + + suspend fun restoreSession() { + _state.value = _state.value.copy(savedProfile = profileStore.read()) + loadCountries() + loadFeedbackAdminStatus() + runCatching { restApi.session() } + .onSuccess { session -> + _state.value = _state.value.copy( + expressSessionId = session.sessionId, + isLoggedIn = session.loggedIn && session.user != null, + currentUser = session.user, + errorMessage = null + ) + if (session.loggedIn && session.user != null) { + resetTimeout() + } + connectSocket(session.sessionId) + } + .onFailure { error -> _state.value = _state.value.copy(errorMessage = error.message) } + } + + suspend fun loadCountries() { + runCatching { restApi.countries() } + .onSuccess { countries -> + val locale = Locale.getDefault() + val options = countries.map { (englishName, code) -> + val normalizedCode = code.uppercase(Locale.US) + val localizedName = Locale.Builder().setRegion(normalizedCode).build().getDisplayCountry(locale) + .takeIf { it.isNotBlank() } + ?: englishName + CountryOption( + englishName = englishName, + displayName = localizedName, + isoCode = code + ) + }.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + + _state.value = _state.value.copy(countries = options) + } + .onFailure { error -> + _state.value = _state.value.copy(errorMessage = "Country list could not be loaded: ${error.message}") + } + } + + suspend fun login(userName: String, gender: String, age: Int, country: String) { + profileStore.write(SavedProfile(userName, gender, age, country)) + val session = runCatching { restApi.session() }.getOrNull() + val sessionId = session?.sessionId ?: _state.value.expressSessionId + _state.value = _state.value.copy(expressSessionId = sessionId) + if (!socketClient.isConnected) { + connectSocket(sessionId) + } + socketClient.login(userName, gender, age, country, sessionId) + resetTimeout() + } + + suspend fun logout() { + runCatching { restApi.logout() } + socketClient.disconnect() + cookieJar.clear() + _state.value = ChatState(savedProfile = profileStore.read(), countries = _state.value.countries) + } + + fun connectSocket(expressSessionId: String? = _state.value.expressSessionId) { + socketClient.connect() + expressSessionId?.let { socketClient.setSessionId(it) } + } + + fun openConversation(userName: String) { + _state.value = _state.value.copy(currentConversation = userName, messages = emptyList()) + socketClient.requestConversation(userName) + resetTimeout() + } + + fun closeConversation() { + _state.value = _state.value.copy(currentConversation = null, messages = emptyList()) + } + + fun sendMessage(text: String) { + val trimmed = text.trim() + if (trimmed.isEmpty()) return + val target = _state.value.currentConversation + val isCommand = trimmed.startsWith("/") + if (target == null && !isCommand) return + + socketClient.sendMessage(target, trimmed) + if (!isCommand) { + _state.value = _state.value.copy( + messages = _state.value.messages + ChatMessageDto( + from = _state.value.currentUser?.userName.orEmpty(), + to = target, + message = trimmed, + timestamp = java.time.Instant.now().toString() + ) + ) + } + resetTimeout() + } + + fun sendImage(toUserName: String, imageCode: String, imageUrl: String) { + val absoluteUrl = if (imageUrl.startsWith("http")) imageUrl else AppConfig.baseUrl + imageUrl + socketClient.sendImage(toUserName, imageCode, absoluteUrl) + _state.value = _state.value.copy( + messages = _state.value.messages + ChatMessageDto( + from = _state.value.currentUser?.userName.orEmpty(), + to = toUserName, + message = absoluteUrl, + timestamp = java.time.Instant.now().toString(), + isImage = true, + imageUrl = absoluteUrl, + imageCode = imageCode + ) + ) + resetTimeout() + } + + fun setImageUploadState(inProgress: Boolean, message: String? = null) { + _state.value = _state.value.copy( + isUploadingImage = inProgress, + imageUploadMessage = message + ) + } + + suspend fun uploadImage(part: MultipartBody.Part) = restApi.uploadImage(part) + + suspend fun loadFeedback() { + runCatching { restApi.feedback() } + .onSuccess { response -> + _state.value = _state.value.copy(feedbackItems = response.items, feedbackMessage = null) + } + .onFailure { error -> + _state.value = _state.value.copy(feedbackMessage = error.message) + } + } + + suspend fun submitFeedback(comment: String) { + val trimmed = comment.trim() + if (trimmed.isEmpty()) return + + val profile = _state.value.savedProfile + runCatching { + restApi.submitFeedback( + FeedbackRequest( + name = profile.nickname, + age = profile.age, + country = profile.country, + gender = profile.gender, + comment = trimmed + ) + ) + }.onSuccess { + _state.value = _state.value.copy(feedbackMessage = "Feedback saved") + loadFeedback() + }.onFailure { error -> + _state.value = _state.value.copy(feedbackMessage = error.message) + } + } + + suspend fun loadFeedbackAdminStatus() { + runCatching { restApi.feedbackAdminStatus() } + .onSuccess { response -> + _state.value = _state.value.copy( + feedbackAdminAuthenticated = response.authenticated, + feedbackAdminUserName = response.username, + feedbackAdminError = null + ) + } + .onFailure { + _state.value = _state.value.copy( + feedbackAdminAuthenticated = false, + feedbackAdminUserName = null + ) + } + } + + suspend fun loginFeedbackAdmin(username: String, password: String) { + runCatching { restApi.feedbackAdminLogin(FeedbackAdminLoginRequest(username, password)) } + .onSuccess { response -> + _state.value = _state.value.copy( + feedbackAdminAuthenticated = true, + feedbackAdminUserName = response.username, + feedbackAdminError = null + ) + loadFeedback() + } + .onFailure { error -> + _state.value = _state.value.copy(feedbackAdminError = error.message) + } + } + + suspend fun logoutFeedbackAdmin() { + runCatching { restApi.feedbackAdminLogout() } + _state.value = _state.value.copy( + feedbackAdminAuthenticated = false, + feedbackAdminUserName = null, + feedbackAdminError = null + ) + } + + suspend fun deleteFeedback(id: String) { + runCatching { restApi.deleteFeedback(id) } + .onSuccess { loadFeedback() } + .onFailure { error -> + _state.value = _state.value.copy(feedbackAdminError = error.message) + } + } + + suspend fun loadPartners() { + runCatching { restApi.partners() } + .onSuccess { links -> + _state.value = _state.value.copy(partnerLinks = links, partnersError = null) + } + .onFailure { error -> + _state.value = _state.value.copy(partnersError = error.message) + } + } + + fun search(nameIncludes: String?, minAge: Int?, maxAge: Int?, countries: List, genders: List) { + socketClient.userSearch(nameIncludes, minAge, maxAge, countries, genders) + resetTimeout() + } + + fun requestInbox() { + socketClient.requestOpenConversations() + resetTimeout() + } + + fun requestHistory() { + socketClient.requestHistory() + resetTimeout() + } + + fun blockUser(userName: String) { + socketClient.blockUser(userName) + resetTimeout() + } + + fun unblockUser(userName: String) { + socketClient.unblockUser(userName) + resetTimeout() + } + + private fun startTimeoutTicker() { + if (timeoutTickerStarted) return + timeoutTickerStarted = true + scope.launch { + while (isActive) { + delay(1_000) + val current = _state.value + if (!current.isLoggedIn) continue + val next = (current.remainingSecondsToTimeout - 1).coerceAtLeast(0) + _state.value = current.copy(remainingSecondsToTimeout = next) + if (next == 0) { + logout() + } + } + } + } + + private fun resetTimeout() { + if (_state.value.isLoggedIn || _state.value.currentUser != null) { + _state.value = _state.value.copy(remainingSecondsToTimeout = 1800) + } + } + + private fun reduce(event: SocketEvent) { + val current = _state.value + _state.value = when (event) { + is SocketEvent.ConnectionChanged -> current.copy(isConnected = event.connected) + is SocketEvent.Connected -> { + event.sessionId?.let { socketClient.setSessionId(it) } + current.copy( + expressSessionId = event.sessionId ?: current.expressSessionId, + isLoggedIn = event.loggedIn || current.isLoggedIn, + currentUser = event.user ?: current.currentUser, + errorMessage = null, + remainingSecondsToTimeout = if (event.loggedIn || current.isLoggedIn) 1800 else current.remainingSecondsToTimeout + ) + } + is SocketEvent.LoginSuccess -> current.copy( + expressSessionId = event.sessionId ?: current.expressSessionId, + isLoggedIn = true, + currentUser = event.user, + errorMessage = null, + remainingSecondsToTimeout = 1800 + ) + is SocketEvent.UserList -> current.copy(users = event.users) + is SocketEvent.IncomingMessage -> { + val active = current.currentConversation == event.message.from + current.copy( + messages = if (active) current.messages + event.message else current.messages, + unreadChatsCount = if (active) current.unreadChatsCount else current.unreadChatsCount + 1, + remainingSecondsToTimeout = 1800 + ) + } + is SocketEvent.MessageSent -> current + is SocketEvent.Conversation -> current.copy( + currentConversation = event.withUserName, + messages = event.messages, + unreadChatsCount = maxOf(0, current.unreadChatsCount - 1), + remainingSecondsToTimeout = 1800 + ) + is SocketEvent.SearchResults -> current.copy(searchResults = event.results) + is SocketEvent.HistoryResults -> current.copy(historyResults = event.results) + is SocketEvent.InboxResults -> current.copy(inboxResults = event.results) + is SocketEvent.UnreadChats -> current.copy(unreadChatsCount = event.count) + is SocketEvent.UserBlocked -> current.copy(errorMessage = "${event.userName} blocked") + is SocketEvent.UserUnblocked -> current.copy(errorMessage = "${event.userName} unblocked") + is SocketEvent.CommandResult -> current.copy( + commandLines = event.lines, + commandKind = event.kind, + commandTable = null, + awaitingLoginUsername = event.kind == "loginPromptUsername", + awaitingLoginPassword = event.kind == "loginPromptPassword", + errorMessage = if (event.kind == "info" || event.kind.startsWith("login")) event.lines.joinToString(" | ") else current.errorMessage + ) + is SocketEvent.CommandTable -> current.copy( + commandTable = CommandTableState(event.title, event.columns, event.rows) + ) + is SocketEvent.Error -> current.copy(errorMessage = event.message) + } + } +} + +data class CommandTableState( + val title: String, + val columns: List, + val rows: List> +) + +data class ChatState( + val isConnected: Boolean = false, + val isLoggedIn: Boolean = false, + val expressSessionId: String? = null, + val currentUser: UserDto? = null, + val users: List = emptyList(), + val currentConversation: String? = null, + val messages: List = emptyList(), + val searchResults: List = emptyList(), + val inboxResults: List = emptyList(), + val historyResults: List = emptyList(), + val countries: List = emptyList(), + val feedbackItems: List = emptyList(), + val feedbackMessage: String? = null, + val feedbackAdminAuthenticated: Boolean = false, + val feedbackAdminUserName: String? = null, + val feedbackAdminError: String? = null, + val partnerLinks: List = emptyList(), + val partnersError: String? = null, + val savedProfile: SavedProfile = SavedProfile(), + val commandLines: List = emptyList(), + val commandKind: String? = null, + val commandTable: CommandTableState? = null, + val awaitingLoginUsername: Boolean = false, + val awaitingLoginPassword: Boolean = false, + val remainingSecondsToTimeout: Int = 1800, + val isUploadingImage: Boolean = false, + val imageUploadMessage: String? = null, + val unreadChatsCount: Int = 0, + val errorMessage: String? = null +) diff --git a/android/app/src/main/java/net/ypchat/app/ui/ChatViewModel.kt b/android/app/src/main/java/net/ypchat/app/ui/ChatViewModel.kt new file mode 100644 index 0000000..7412710 --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/ui/ChatViewModel.kt @@ -0,0 +1,124 @@ +package net.ypchat.app.ui + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import net.ypchat.app.data.repository.ChatRepository +import net.ypchat.app.data.repository.ChatState +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody + +class ChatViewModel(private val repository: ChatRepository) : ViewModel() { + val state: StateFlow = repository.state + + init { + viewModelScope.launch { repository.restoreSession() } + } + + fun login(userName: String, gender: String, age: Int, country: String) { + viewModelScope.launch { repository.login(userName, gender, age, country) } + } + + fun loadCountries() { + viewModelScope.launch { repository.loadCountries() } + } + + fun logout() { + viewModelScope.launch { repository.logout() } + } + + fun openConversation(userName: String) = repository.openConversation(userName) + fun closeConversation() = repository.closeConversation() + fun sendMessage(text: String) = repository.sendMessage(text) + fun sendImage(context: Context, uri: Uri) { + val target = state.value.currentConversation ?: return + viewModelScope.launch { + val resolver = context.applicationContext.contentResolver + val mimeType = resolver.getType(uri) ?: "image/*" + repository.setImageUploadState(true) + val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes == null) { + repository.setImageUploadState(false, "Image could not be opened") + return@launch + } + if (bytes.size > MAX_IMAGE_BYTES) { + repository.setImageUploadState(false, "Image exceeds 5 MB") + return@launch + } + + val fileName = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && nameIndex >= 0) cursor.getString(nameIndex) else null + } ?: "ypchat-image" + + val body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("image", fileName, body) + runCatching { repository.uploadImage(part) } + .onSuccess { response -> + val payload = response.body() + if (response.isSuccessful && payload?.success == true && !payload.code.isNullOrBlank() && !payload.url.isNullOrBlank()) { + repository.sendImage(target, payload.code, payload.url) + repository.setImageUploadState(false, "Image uploaded") + } else { + repository.setImageUploadState(false, payload?.error ?: "Image upload failed") + } + } + .onFailure { error -> + repository.setImageUploadState(false, error.message ?: "Image upload failed") + } + } + } + + fun search(name: String, minAge: Int?, maxAge: Int?, countries: List, genders: List) { + repository.search(name.takeIf { it.isNotBlank() }, minAge, maxAge, countries, genders) + } + fun requestInbox() = repository.requestInbox() + fun requestHistory() = repository.requestHistory() + fun loadFeedback() { + viewModelScope.launch { repository.loadFeedback() } + } + + fun submitFeedback(comment: String) { + viewModelScope.launch { repository.submitFeedback(comment) } + } + + fun loadFeedbackAdminStatus() { + viewModelScope.launch { repository.loadFeedbackAdminStatus() } + } + + fun loginFeedbackAdmin(username: String, password: String) { + viewModelScope.launch { repository.loginFeedbackAdmin(username, password) } + } + + fun logoutFeedbackAdmin() { + viewModelScope.launch { repository.logoutFeedbackAdmin() } + } + + fun deleteFeedback(id: String) { + viewModelScope.launch { repository.deleteFeedback(id) } + } + + fun loadPartners() { + viewModelScope.launch { repository.loadPartners() } + } + + fun blockCurrentUser() = state.value.currentConversation?.let(repository::blockUser) + fun unblockCurrentUser() = state.value.currentConversation?.let(repository::unblockUser) + + private companion object { + const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 + } +} + +class ChatViewModelFactory(private val repository: ChatRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return ChatViewModel(repository) as T + } +} diff --git a/android/app/src/main/java/net/ypchat/app/ui/YpChatRoot.kt b/android/app/src/main/java/net/ypchat/app/ui/YpChatRoot.kt new file mode 100644 index 0000000..b5a28db --- /dev/null +++ b/android/app/src/main/java/net/ypchat/app/ui/YpChatRoot.kt @@ -0,0 +1,1039 @@ +package net.ypchat.app.ui + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.Image +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import net.ypchat.app.R +import net.ypchat.app.data.model.ChatMessageDto +import net.ypchat.app.data.model.CountryOption +import net.ypchat.app.data.model.FeedbackItemDto +import net.ypchat.app.data.model.PartnerLinkDto +import net.ypchat.app.data.model.UserDto +import net.ypchat.app.data.repository.ChatState +import net.ypchat.app.data.repository.CommandTableState +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +enum class AppTab(val labelRes: Int) { + Online(R.string.tab_online), + Search(R.string.tab_search), + Inbox(R.string.tab_inbox), + History(R.string.tab_history), + Console(R.string.tab_console), + More(R.string.tab_more) +} + +private enum class MoreSection { + Overview, Feedback, Partners, Faq, Rules, Safety, Imprint +} + +private val BgApp = Color(0xFFF4F6F5) +private val BgShell = Color(0xFFEDF2EE) +private val SurfaceColor = Color.White +private val SurfaceSubtle = Color(0xFFF6F9F7) +private val SurfaceSoftGreen = Color(0xFFF0F7F2) +private val SurfaceSoftBlue = Color(0xFFF1F5FA) +private val SurfaceSoftRed = Color(0xFFFBEDED) +private val BorderColor = Color(0xFFD7DFD9) +private val TextStrong = Color(0xFF18201B) +private val TextMuted = Color(0xFF637067) +private val Primary700 = Color(0xFF245C3A) +private val Primary600 = Color(0xFF2F6F46) +private val Primary500 = Color(0xFF3D8654) +private val Primary100 = Color(0xFFE7F1EA) +private val Danger = Color(0xFFA24040) + +private data class GenderOption(val value: String, val label: String) +private data class SmileyItem(val token: String, val hexCode: String, val tooltip: String) +private val SmileyItems = listOf( + SmileyItem(":)", "1F642", "Smile"), + SmileyItem(":D", "1F600", "Laugh"), + SmileyItem(":(", "1F641", "Sad"), + SmileyItem(";)", "1F609", "Twinkle"), + SmileyItem(":p", "1F60B", "Tongue"), + SmileyItem(";p", "1F61C", "Twinkle tongue"), + SmileyItem("O)", "1F607", "Angel"), + SmileyItem(":*", "1F617", "Kiss"), + SmileyItem("(h)", "1FA77", "Heart"), + SmileyItem("xD", "1F602", "Laughing hard"), + SmileyItem(":@", "1F635", "Confused"), + SmileyItem(":O", "1F632", "Surprised"), + SmileyItem(":3", "1F63A", "Cat face"), + SmileyItem(":|", "1F610", "Neutral"), + SmileyItem(":/", "1FAE4", "Skeptical"), + SmileyItem(":#", "1F912", "Sick"), + SmileyItem("#)", "1F973", "Partied"), + SmileyItem("%)", "1F974", "Drunk"), + SmileyItem("(t)", "1F44D", "Thumbs up"), + SmileyItem(":'(", "1F622", "Cry") +) + +@Composable +fun YpChatRoot(viewModel: ChatViewModel) { + val state by viewModel.state.collectAsState() + + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + if (!state.isLoggedIn) { + LoginScreen(state, onLogin = viewModel::login) + } else { + ChatShell(state, viewModel) + } + } + } +} + +@Composable +private fun LoginScreen(state: ChatState, onLogin: (String, String, Int, String) -> Unit) { + var name by remember { mutableStateOf("") } + var gender by remember { mutableStateOf("") } + var age by remember { mutableStateOf(18.toString()) } + var country by remember { mutableStateOf("Germany") } + val scrollState = rememberScrollState() + + LaunchedEffect(state.savedProfile) { + name = state.savedProfile.nickname + gender = state.savedProfile.gender + age = state.savedProfile.age.toString() + country = state.savedProfile.country + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Brush.verticalGradient(listOf(BgApp, BgShell))) + .verticalScroll(scrollState) + .padding(16.dp), + contentAlignment = Alignment.TopCenter + ) { + Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { + LandingIntroCard() + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = SurfaceColor.copy(alpha = 0.99f)), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFD4DDD6)) + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + stringResource(R.string.profile_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = TextStrong + ) + Text(stringResource(R.string.profile_copy), color = TextMuted) + AppTextField(name, { name = it }, stringResource(R.string.label_nick)) + GenderDropdown(value = gender, onValueChange = { gender = it }) + OutlinedTextField( + value = age, + onValueChange = { age = it.filter(Char::isDigit) }, + label = { Text(stringResource(R.string.label_age)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + colors = appTextFieldColors() + ) + CountryDropdown(value = country, countries = state.countries, onValueChange = { country = it }) + Button( + onClick = { onLogin(name, gender, age.toIntOrNull() ?: 18, country) }, + enabled = name.length >= 3 && gender.isNotBlank() && country.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Primary600, contentColor = Color.White) + ) { + Text(stringResource(R.string.button_start_chat)) + } + state.errorMessage?.let { Text(localizeRuntimeMessage(it), color = Danger) } + Text( + text = if (state.isConnected) stringResource(R.string.socket_connected) else stringResource(R.string.socket_connecting), + color = TextMuted + ) + } + } + } + } +} + +@Composable +private fun LandingIntroCard() { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFF4F8F5).copy(alpha = 0.98f)), + border = androidx.compose.foundation.BorderStroke(1.dp, Color(0xFFCFE0D3)) + ) { + Column( + modifier = Modifier + .background(Brush.radialGradient(colors = listOf(Color(0x473D8654), Color.Transparent), radius = 520f)) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(R.string.landing_eyebrow), + color = Color(0xFF496254), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = stringResource(R.string.landing_title), + color = TextStrong, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Black + ) + Text(text = stringResource(R.string.landing_copy), color = Color(0xFF4F5D54), style = MaterialTheme.typography.bodyMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FeatureChip(stringResource(R.string.feature_worldwide_chat)) + FeatureChip(stringResource(R.string.feature_image_exchange)) + } + FeatureChip(stringResource(R.string.feature_compact_controls)) + } + } +} + +@Composable +private fun FeatureChip(text: String) { + Text( + text = text, + modifier = Modifier + .background(Color(0xFFE2EFE5), RoundedCornerShape(999.dp)) + .padding(horizontal = 12.dp, vertical = 7.dp), + color = Primary700, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) +} + +@Composable +private fun ChatShell(state: ChatState, viewModel: ChatViewModel) { + var selectedTab by rememberSaveable { mutableStateOf(AppTab.Online) } + var moreSection by rememberSaveable { mutableStateOf(MoreSection.Overview) } + + LaunchedEffect(selectedTab) { + when (selectedTab) { + AppTab.Inbox -> viewModel.requestInbox() + AppTab.History -> viewModel.requestHistory() + AppTab.More -> { + viewModel.loadFeedback() + viewModel.loadFeedbackAdminStatus() + viewModel.loadPartners() + } + else -> Unit + } + } + + Scaffold( + topBar = { TopStatusBar(state, onLogout = viewModel::logout) }, + bottomBar = { + if (state.currentConversation == null) { + NavigationBar { + AppTab.entries.forEach { tab -> + val baseLabel = stringResource(tab.labelRes) + val label = if (tab == AppTab.Inbox && state.unreadChatsCount > 0) "$baseLabel (${state.unreadChatsCount})" else baseLabel + NavigationBarItem( + selected = selectedTab == tab, + onClick = { + selectedTab = tab + if (tab != AppTab.More) moreSection = MoreSection.Overview + }, + icon = {}, + label = { Text(label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = Primary700, + selectedTextColor = Primary700, + indicatorColor = Primary100, + unselectedTextColor = TextMuted + ) + ) + } + } + } + } + ) { padding -> + Box(modifier = Modifier.padding(padding).fillMaxSize()) { + if (state.currentConversation != null) { + ChatScreen(state, viewModel) + } else { + when (selectedTab) { + AppTab.Online -> UserListScreen(state.users, state.countries, onUserClick = viewModel::openConversation) + AppTab.Search -> SearchScreen(state, viewModel) + AppTab.Inbox -> InboxScreen(state, viewModel) + AppTab.History -> HistoryScreen(state, viewModel) + AppTab.Console -> ConsoleScreen(state, viewModel) + AppTab.More -> MoreScreen(state, viewModel, moreSection) { moreSection = it } + } + } + } + } +} + +@Composable +private fun TopStatusBar(state: ChatState, onLogout: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Brush.verticalGradient(listOf(Color(0xFFD0E8D8), Color(0xFFEBF5EE), Color(0xFFF7FAF8)))) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.app_name), color = Primary700, fontWeight = FontWeight.Bold) + Text( + "${state.currentUser?.userName.orEmpty()} - ${if (state.isConnected) stringResource(R.string.status_online) else stringResource(R.string.status_connecting)}", + color = TextMuted + ) + Text(stringResource(R.string.timeout_in, formatTimeout(state.remainingSecondsToTimeout)), color = TextMuted) + } + TextButton(onClick = onLogout) { Text(stringResource(R.string.logout), color = Primary700) } + } +} + +@Composable +private fun UserListScreen(users: List, countries: List, onUserClick: (String) -> Unit) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { Text(stringResource(R.string.tab_online), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) } + if (users.isEmpty()) item { Text(stringResource(R.string.no_users_online)) } + items(users) { user -> UserRow(user, countries, onUserClick) } + } +} + +@Composable +private fun UserRow(user: UserDto, countries: List, onUserClick: (String) -> Unit) { + val countryLabel = remember(user.country, user.isoCountryCode, countries) { displayCountryName(user, countries) } + Card(modifier = Modifier.fillMaxWidth().clickable { onUserClick(user.userName) }) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Text(user.userName, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text("${user.age} - $countryLabel", color = TextMuted) + } + } +} + +@Composable +private fun SearchScreen(state: ChatState, viewModel: ChatViewModel) { + var query by remember { mutableStateOf("") } + var minAge by remember { mutableStateOf("") } + var maxAge by remember { mutableStateOf("") } + var country by remember { mutableStateOf("") } + var gender by remember { mutableStateOf("") } + var localError by remember { mutableStateOf(null) } + var hasSearched by remember { mutableStateOf(false) } + val minAgeError = stringResource(R.string.search_min_age_error) + + Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.tab_search), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + OutlinedTextField(query, { query = it }, label = { Text(stringResource(R.string.search_username_includes)) }, modifier = Modifier.fillMaxWidth(), colors = appTextFieldColors()) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = minAge, + onValueChange = { minAge = it.filter(Char::isDigit) }, + label = { Text(stringResource(R.string.search_from_age)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = appTextFieldColors() + ) + OutlinedTextField( + value = maxAge, + onValueChange = { maxAge = it.filter(Char::isDigit) }, + label = { Text(stringResource(R.string.search_to_age)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.weight(1f), + colors = appTextFieldColors() + ) + } + CountryDropdown(value = country, countries = state.countries, allLabel = stringResource(R.string.search_all), onValueChange = { country = it }) + GenderDropdown(value = gender, allLabel = stringResource(R.string.search_all), onValueChange = { gender = it }) + localError?.let { Text(it, color = Danger) } + Button( + onClick = { + val min = minAge.toIntOrNull() + val max = maxAge.toIntOrNull() + if (min != null && max != null && min > max) { + localError = minAgeError + return@Button + } + localError = null + hasSearched = true + viewModel.search( + name = query, + minAge = min, + maxAge = max, + countries = country.takeIf { it.isNotBlank() }?.let(::listOf).orEmpty(), + genders = gender.takeIf { it.isNotBlank() }?.let(::listOf).orEmpty() + ) + }, + colors = ButtonDefaults.buttonColors(containerColor = Primary600), + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.search_button)) } + if (hasSearched && state.searchResults.isEmpty()) { + Text(stringResource(R.string.search_no_results), color = TextMuted) + } + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(state.searchResults) { user -> UserRow(user, state.countries, viewModel::openConversation) } + } + } +} + +@Composable +private fun InboxScreen(state: ChatState, viewModel: ChatViewModel) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { Text(stringResource(R.string.tab_inbox), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) } + if (state.inboxResults.isEmpty()) item { Text(stringResource(R.string.inbox_empty)) } + items(state.inboxResults) { item -> + Card(modifier = Modifier.fillMaxWidth().clickable { viewModel.openConversation(item.userName) }) { + Row(modifier = Modifier.padding(16.dp)) { + Text(item.userName, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Text(stringResource(R.string.inbox_new_count, item.unreadCount)) + } + } + } + } +} + +@Composable +private fun HistoryScreen(state: ChatState, viewModel: ChatViewModel) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + item { Text(stringResource(R.string.tab_history), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) } + if (state.historyResults.isEmpty()) item { Text(stringResource(R.string.history_empty)) } + items(state.historyResults) { item -> + Card(modifier = Modifier.fillMaxWidth().clickable { viewModel.openConversation(item.userName) }) { + Column(modifier = Modifier.padding(16.dp)) { + Text(item.userName, fontWeight = FontWeight.Bold) + Text(item.lastMessage?.message ?: stringResource(R.string.no_message), color = TextMuted) + } + } + } + } +} + +@Composable +private fun ConsoleScreen(state: ChatState, viewModel: ChatViewModel) { + var input by remember { mutableStateOf("") } + val placeholder = when { + state.awaitingLoginUsername -> stringResource(R.string.feedback_admin_user) + state.awaitingLoginPassword -> stringResource(R.string.feedback_admin_password) + else -> stringResource(R.string.console_placeholder) + } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.console_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(placeholder) }, + colors = appTextFieldColors() + ) + Button( + onClick = { + viewModel.sendMessage(input) + input = "" + }, + enabled = input.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = Primary600), + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.console_send)) } + + if (state.commandLines.isEmpty() && state.commandTable == null) { + Text(stringResource(R.string.console_empty), color = TextMuted) + } else { + state.commandLines.forEach { line -> + ConsoleLineCard(line = localizeRuntimeMessage(line), kind = state.commandKind) + } + state.commandTable?.let { table -> CommandTableCard(table) } + } + } +} + +@Composable +private fun CommandTableCard(table: CommandTableState) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = SurfaceColor), + border = androidx.compose.foundation.BorderStroke(1.dp, BorderColor) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(table.title, fontWeight = FontWeight.Bold, color = TextStrong) + if (table.columns.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Primary100, RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + table.columns.forEach { Text(it, fontWeight = FontWeight.Bold, color = Primary700, modifier = Modifier.weight(1f, fill = false)) } + } + } + table.rows.forEach { row -> + Row( + modifier = Modifier + .fillMaxWidth() + .background(SurfaceSubtle, RoundedCornerShape(12.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + row.forEach { value -> Text(value, color = TextMuted, modifier = Modifier.weight(1f, fill = false)) } + } + } + } + } +} + +@Composable +private fun ConsoleLineCard(line: String, kind: String?) { + val normalizedKind = kind.orEmpty() + val container = when { + normalizedKind.startsWith("login") -> SurfaceSoftBlue + normalizedKind == "info" -> SurfaceSoftGreen + normalizedKind == "error" -> SurfaceSoftRed + else -> SurfaceSubtle + } + val border = when { + normalizedKind.startsWith("login") -> Color(0xFFC9DBEE) + normalizedKind == "info" -> Color(0xFFCEE3D3) + normalizedKind == "error" -> Color(0xFFF0CACA) + else -> BorderColor + } + val textColor = if (normalizedKind == "error") Danger else TextStrong + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = container), + border = androidx.compose.foundation.BorderStroke(1.dp, border) + ) { + Text(line, modifier = Modifier.padding(14.dp), color = textColor) + } +} + +@Composable +private fun MoreScreen(state: ChatState, viewModel: ChatViewModel, section: MoreSection, onSectionChange: (MoreSection) -> Unit) { + when (section) { + MoreSection.Overview -> MoreOverviewScreen(onSectionChange) + MoreSection.Feedback -> FeedbackScreen(state, viewModel) { onSectionChange(MoreSection.Overview) } + MoreSection.Partners -> PartnersScreen(state.partnerLinks, state.partnersError) { onSectionChange(MoreSection.Overview) } + MoreSection.Faq -> StaticContentScreen(stringResource(R.string.faq_title), stringResource(R.string.faq_body)) { onSectionChange(MoreSection.Overview) } + MoreSection.Rules -> StaticContentScreen(stringResource(R.string.rules_title), stringResource(R.string.rules_body)) { onSectionChange(MoreSection.Overview) } + MoreSection.Safety -> StaticContentScreen(stringResource(R.string.safety_title), stringResource(R.string.safety_body)) { onSectionChange(MoreSection.Overview) } + MoreSection.Imprint -> StaticContentScreen(stringResource(R.string.imprint_title), stringResource(R.string.imprint_body)) { onSectionChange(MoreSection.Overview) } + } +} + +@Composable +private fun MoreOverviewScreen(onSectionChange: (MoreSection) -> Unit) { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { Text(stringResource(R.string.more_title), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) } + item { MoreLinkCard(stringResource(R.string.more_feedback), stringResource(R.string.feedback_comment)) { onSectionChange(MoreSection.Feedback) } } + item { MoreLinkCard(stringResource(R.string.more_partners), stringResource(R.string.partners_intro)) { onSectionChange(MoreSection.Partners) } } + item { MoreLinkCard(stringResource(R.string.more_faq), stringResource(R.string.faq_intro)) { onSectionChange(MoreSection.Faq) } } + item { MoreLinkCard(stringResource(R.string.more_rules), stringResource(R.string.rules_intro)) { onSectionChange(MoreSection.Rules) } } + item { MoreLinkCard(stringResource(R.string.more_safety), stringResource(R.string.safety_intro)) { onSectionChange(MoreSection.Safety) } } + item { MoreLinkCard(stringResource(R.string.more_imprint), stringResource(R.string.imprint_intro)) { onSectionChange(MoreSection.Imprint) } } + } +} + +@Composable +private fun MoreLinkCard(title: String, subtitle: String, onClick: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, fontWeight = FontWeight.Bold, color = TextStrong) + Text(subtitle, color = TextMuted) + } + } +} + +@Composable +private fun FeedbackScreen(state: ChatState, viewModel: ChatViewModel, onBack: () -> Unit) { + var comment by remember { mutableStateOf("") } + var adminUser by remember { mutableStateOf("") } + var adminPassword by remember { mutableStateOf("") } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + BackHeader(stringResource(R.string.feedback_title), onBack) + + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text(stringResource(R.string.feedback_comment)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + colors = appTextFieldColors() + ) + Button( + onClick = { + viewModel.submitFeedback(comment) + comment = "" + }, + enabled = comment.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = Primary600), + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.feedback_send)) } + state.feedbackMessage?.let { Text(localizeRuntimeMessage(it), color = TextMuted) } + + Card(modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = SurfaceSubtle)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (state.feedbackAdminAuthenticated) { + Text("${stringResource(R.string.feedback_admin_user)}: ${state.feedbackAdminUserName.orEmpty()}", fontWeight = FontWeight.Bold) + Button( + onClick = viewModel::logoutFeedbackAdmin, + colors = ButtonDefaults.buttonColors(containerColor = Primary500) + ) { Text(stringResource(R.string.feedback_admin_logout)) } + } else { + OutlinedTextField( + value = adminUser, + onValueChange = { adminUser = it }, + label = { Text(stringResource(R.string.feedback_admin_user)) }, + modifier = Modifier.fillMaxWidth(), + colors = appTextFieldColors() + ) + OutlinedTextField( + value = adminPassword, + onValueChange = { adminPassword = it }, + label = { Text(stringResource(R.string.feedback_admin_password)) }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + colors = appTextFieldColors() + ) + Button( + onClick = { viewModel.loginFeedbackAdmin(adminUser, adminPassword) }, + colors = ButtonDefaults.buttonColors(containerColor = Primary500), + modifier = Modifier.fillMaxWidth() + ) { Text(stringResource(R.string.feedback_admin_login)) } + } + state.feedbackAdminError?.let { Text(localizeRuntimeMessage(it), color = Danger) } + } + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (state.feedbackItems.isEmpty()) item { Text(stringResource(R.string.feedback_empty), color = TextMuted) } + items(state.feedbackItems) { item -> FeedbackRow(item, state.feedbackAdminAuthenticated, viewModel) } + } + } +} + +@Composable +private fun FeedbackRow(item: FeedbackItemDto, adminMode: Boolean, viewModel: ChatViewModel) { + val metaSeparator = stringResource(R.string.feedback_meta_separator) + val createdAtLabel = remember(item.createdAt) { formatFeedbackTimestamp(item.createdAt) } + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(item.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.anonymous), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + if (adminMode) { + TextButton(onClick = { viewModel.deleteFeedback(item.id) }) { Text(stringResource(R.string.feedback_delete), color = Danger) } + } + } + Text(item.comment, color = TextStrong) + val meta = listOfNotNull(item.country, item.age?.toString(), item.gender).filter { it.isNotBlank() } + if (meta.isNotEmpty()) Text(meta.joinToString(metaSeparator), color = TextMuted) + createdAtLabel?.let { + Text(stringResource(R.string.feedback_created_at, it), color = TextMuted, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun PartnersScreen(links: List, error: String?, onBack: () -> Unit) { + val uriHandler = LocalUriHandler.current + + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + item { BackHeader(stringResource(R.string.partners_title), onBack) } + item { Text(stringResource(R.string.partners_intro), color = TextMuted) } + error?.let { item { Text(localizeRuntimeMessage(it), color = Danger) } } + items(links) { link -> + Card(modifier = Modifier.fillMaxWidth().clickable { uriHandler.openUri(link.url) }) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(link.pageName, fontWeight = FontWeight.Bold, color = TextStrong) + Text(link.url, color = Primary700) + Text(stringResource(R.string.external_link), color = TextMuted) + } + } + } + } +} + +@Composable +private fun StaticContentScreen(title: String, body: String, onBack: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + BackHeader(title, onBack) + Text(body, color = TextStrong) + } +} + +@Composable +private fun BackHeader(title: String, onBack: () -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = onBack) { Text(stringResource(R.string.more_back), color = Primary700) } + Spacer(Modifier.width(8.dp)) + Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun ChatScreen(state: ChatState, viewModel: ChatViewModel) { + var draft by remember { mutableStateOf("") } + var showSmileys by remember { mutableStateOf(false) } + val context = LocalContext.current + val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) viewModel.sendImage(context, uri) + } + + Column(modifier = Modifier.fillMaxSize()) { + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = viewModel::closeConversation) { Text(stringResource(R.string.back)) } + Text(state.currentConversation.orEmpty(), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + TextButton(onClick = viewModel::blockCurrentUser) { Text(stringResource(R.string.block)) } + TextButton(onClick = viewModel::unblockCurrentUser) { Text(stringResource(R.string.unblock)) } + } + HorizontalDivider() + LazyColumn(modifier = Modifier.weight(1f).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(state.messages) { message -> MessageBubble(message, state.currentUser?.userName.orEmpty()) } + } + if (state.isUploadingImage || !state.imageUploadMessage.isNullOrBlank()) { + UploadStatusBanner(state) + } + Row(modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + draft, + { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(R.string.message_placeholder)) }, + colors = appTextFieldColors(), + enabled = !state.isUploadingImage + ) + Spacer(Modifier.width(8.dp)) + IconImageButton( + iconRes = R.drawable.smileys_button, + contentDescription = stringResource(R.string.button_smileys), + onClick = { showSmileys = !showSmileys }, + enabled = !state.isUploadingImage + ) + Spacer(Modifier.width(8.dp)) + IconImageButton( + iconRes = R.drawable.image_button, + contentDescription = stringResource(R.string.button_image), + onClick = { imagePicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + enabled = !state.isUploadingImage + ) + Spacer(Modifier.width(8.dp)) + Button( + onClick = { viewModel.sendMessage(draft); draft = "" }, + colors = ButtonDefaults.buttonColors(containerColor = Primary600), + enabled = draft.isNotBlank() && !state.isUploadingImage + ) { + Text(stringResource(R.string.button_send)) + } + } + if (showSmileys) { + LazyRow(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(SmileyItems) { smiley -> + Text( + text = smileyEmoji(smiley.hexCode), + modifier = Modifier + .background(Primary100, RoundedCornerShape(999.dp)) + .clickable { + draft += smiley.token + showSmileys = false + } + .padding(horizontal = 12.dp, vertical = 8.dp), + color = Primary700, + fontWeight = FontWeight.Bold + ) + } + } + } + state.errorMessage?.let { Text(localizeRuntimeMessage(it), modifier = Modifier.padding(horizontal = 12.dp), color = Danger) } + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun UploadStatusBanner(state: ChatState) { + val message = when { + state.isUploadingImage -> stringResource(R.string.image_upload_in_progress) + state.imageUploadMessage != null -> localizeRuntimeMessage(state.imageUploadMessage) + else -> return + } + val backgroundColor = when { + state.isUploadingImage -> SurfaceSoftBlue + state.imageUploadMessage == "Image uploaded" -> SurfaceSoftGreen + else -> SurfaceSoftRed + } + val textColor = when { + state.isUploadingImage -> Primary700 + state.imageUploadMessage == "Image uploaded" -> Primary700 + else -> Danger + } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + colors = CardDefaults.cardColors(containerColor = backgroundColor) + ) { + Text( + text = message, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + color = textColor, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun IconImageButton(iconRes: Int, contentDescription: String, onClick: () -> Unit, enabled: Boolean = true) { + TextButton( + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.textButtonColors( + containerColor = Primary100, + contentColor = Primary700, + disabledContainerColor = SurfaceSubtle, + disabledContentColor = TextMuted + ) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = contentDescription, + modifier = Modifier.height(20.dp) + ) + } +} + +@Composable +private fun MessageBubble(message: ChatMessageDto, currentUserName: String) { + val self = message.from == currentUserName + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = if (self) Arrangement.End else Arrangement.Start) { + Column( + modifier = Modifier + .background(if (self) Color(0xFFDFF0E4) else SurfaceSubtle, RoundedCornerShape(18.dp)) + .padding(12.dp) + ) { + if (message.isImage && !message.imageUrl.isNullOrBlank()) { + AsyncImage(model = message.imageUrl, contentDescription = stringResource(R.string.image_message), modifier = Modifier.fillMaxWidth().height(220.dp)) + } else { + Text(message.message) + } + } + } +} + +@Composable +private fun AppTextField(value: String, onValueChange: (String) -> Unit, label: String) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + modifier = Modifier.fillMaxWidth(), + colors = appTextFieldColors() + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun GenderDropdown(value: String, allLabel: String? = null, onValueChange: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val baseGenderOptions = listOf( + GenderOption("F", stringResource(R.string.gender_female)), + GenderOption("M", stringResource(R.string.gender_male)), + GenderOption("P", stringResource(R.string.gender_pair)), + GenderOption("TF", stringResource(R.string.gender_trans_mf)), + GenderOption("TM", stringResource(R.string.gender_trans_fm)) + ) + val genderOptions = allLabel?.let { listOf(GenderOption("", it)) + baseGenderOptions } ?: baseGenderOptions + val selectedLabel = genderOptions.firstOrNull { it.value == value }?.label.orEmpty() + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.label_gender)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(), + colors = appTextFieldColors() + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + genderOptions.forEach { option -> + DropdownMenuItem( + text = { Text(option.label) }, + onClick = { + onValueChange(option.value) + expanded = false + } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CountryDropdown(value: String, countries: List, allLabel: String? = null, onValueChange: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val visibleCountries = remember(countries) { if (countries.isEmpty()) listOf(CountryOption("Germany", "Germany", "de")) else countries } + val selectedLabel = if (value.isBlank() && allLabel != null) allLabel else visibleCountries.firstOrNull { it.englishName == value }?.displayName ?: value + + ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { + OutlinedTextField( + value = selectedLabel, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.label_country)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true).fillMaxWidth(), + colors = appTextFieldColors() + ) + ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + allLabel?.let { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + onValueChange("") + expanded = false + } + ) + } + visibleCountries.forEach { country -> + DropdownMenuItem( + text = { Text(country.displayName) }, + onClick = { + onValueChange(country.englishName) + expanded = false + } + ) + } + } + } +} + +private fun displayCountryName(user: UserDto, countries: List): String { + val byEnglishName = countries.firstOrNull { it.englishName == user.country } + if (byEnglishName != null) return byEnglishName.displayName + + val byIsoCode = countries.firstOrNull { it.isoCode.equals(user.isoCountryCode, ignoreCase = true) } + if (byIsoCode != null) return byIsoCode.displayName + + return user.country +} + +@Composable +private fun appTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Primary500, + unfocusedBorderColor = BorderColor, + focusedLabelColor = Primary700, + unfocusedLabelColor = TextMuted, + cursorColor = Primary700, + focusedContainerColor = SurfaceSubtle, + unfocusedContainerColor = Color(0xFFFBFDFB) +) + +@Composable +private fun localizeRuntimeMessage(message: String): String { + return when { + message.startsWith("Country list could not be loaded:") -> + stringResource(R.string.countries_load_error, message.substringAfter(":").trim()) + message == "Image uploaded" -> + stringResource(R.string.image_upload_success) + message == "Image upload failed" -> + stringResource(R.string.image_upload_failed) + message == "Image exceeds 5 MB" -> + stringResource(R.string.image_upload_too_large) + message == "Image could not be opened" -> + stringResource(R.string.image_upload_open_failed) + message.endsWith(" blocked") -> + stringResource(R.string.user_blocked, message.removeSuffix(" blocked")) + message.endsWith(" unblocked") -> + stringResource(R.string.user_unblocked, message.removeSuffix(" unblocked")) + message == "Feedback saved" -> + stringResource(R.string.feedback_saved) + else -> message + } +} + +private fun formatTimeout(totalSeconds: Int): String { + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return "%d:%02d".format(minutes, seconds) +} + +private fun smileyEmoji(hexCode: String): String { + val codePoint = hexCode.toInt(16) + return String(Character.toChars(codePoint)) +} + +private fun formatFeedbackTimestamp(createdAt: String): String? { + if (createdAt.isBlank()) return null + + return runCatching { + val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withZone(ZoneId.systemDefault()) + formatter.format(Instant.parse(createdAt)) + }.getOrNull() +} diff --git a/android/app/src/main/res/drawable-nodpi/app_icon_asset.png b/android/app/src/main/res/drawable-nodpi/app_icon_asset.png new file mode 100644 index 0000000..6bc8f75 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/app_icon_asset.png differ diff --git a/android/app/src/main/res/drawable-nodpi/image_button.png b/android/app/src/main/res/drawable-nodpi/image_button.png new file mode 100644 index 0000000..3ff2d77 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/image_button.png differ diff --git a/android/app/src/main/res/drawable-nodpi/smileys_button.png b/android/app/src/main/res/drawable-nodpi/smileys_button.png new file mode 100644 index 0000000..5beb9cd Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/smileys_button.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..47135c7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,3 @@ + diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..1aa2440 --- /dev/null +++ b/android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,103 @@ + + YPChat + SingleChat + Direkt in den Chat + Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung. + Weltweiter Chat + Bildaustausch + Kompakte Bedienung + Profil starten + Wenige Angaben genügen für den Einstieg. + Bitte gib deinen Nicknamen für den Chat ein + Geschlecht + Alter + Land + Chat starten + Weiblich + Männlich + Paar + Transgender (M->F) + Transgender (F->M) + Socket verbunden + Socket wird verbunden... + online + verbindet... + Online + Suche + Posteingang + Verlauf + Konsole + Mehr + Verlassen + Timeout in %1$s + Noch keine anderen Nutzer online. + Benutzername enthält + Von Alter + Bis Alter + Land + Geschlechter + Alle + Suchen + Keine Ergebnisse. + Das Mindestalter darf nicht größer als das Höchstalter sein. + Keine ungelesenen Chats. + %1$d neu + Noch kein Verlauf. + Keine Nachricht + Zurück + Blockieren + Entsperren + Nachricht + Bild + Senden + Smileys + Bildnachricht + Bild wird hochgeladen... + Bild wurde hochgeladen. + Bild-Upload fehlgeschlagen. + Das Bild ist größer als 5 MB. + Das Bild konnte nicht geöffnet werden. + Eingegangen %1$s + + Länderliste konnte nicht geladen werden: %1$s + %1$s wurde blockiert + %1$s wurde entsperrt + Feedback + Kommentar + Feedback senden + Feedback wurde gespeichert. + Noch kein Feedback vorhanden. + Anonym + Admin-Benutzer + Passwort + Admin-Login + Admin abmelden + Löschen + Konsole + /Befehl oder Admin-Login-Eingabe senden + Senden + Noch keine Konsolen-Ausgabe. + Mehr + Feedback + Partner + FAQ + Regeln + Sicherheit + Impressum + Zur Übersicht + Empfehlungen und befreundete Projekte für unsere Community. + Antworten auf häufige Fragen zum Chat. + Grundregeln für respektvollen Chat. + Tipps für Privatsphäre und sichere Nutzung. + Rechtliche Hinweise und Kontaktdaten. + Externer Link + Häufige Fragen + Chat-Regeln + Sicherheit und Privatsphäre + Impressum + Partner + Wähle einen Nicknamen, gib deine Profildaten an und starte den Chat. Teile keine sensiblen Daten wie Telefonnummern, Adressen, Passwörter oder Zahlungsinformationen. Du kannst Bilder senden, Benutzer blockieren und Feedback für ernste Vorfälle nutzen. + Keine Beleidigungen, Hassrede, illegalen Inhalte, Spam oder unerwünschte Belästigung. Sende nur Bilder, die du teilen darfst, und respektiere die Privatsphäre anderer. + Nutze einen Nicknamen, der dich nicht identifiziert. Teile keine privaten Kontakt- oder Zahlungsdaten. Sei vorsichtig mit Links von Unbekannten und beende Gespräche, die sich falsch anfühlen. Nutze Blockieren und Feedback bei schweren Vorfällen. + Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Kontakt: tsschulz@tsschulz.de. Für externe Links sind deren Betreiber verantwortlich. + diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..d181dcd --- /dev/null +++ b/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,41 @@ + + Entrar al chat + Compacto, rápido y sin rodeos. Crea tu perfil y empieza una conversación al instante. + Chat mundial + Intercambio de imágenes + Uso compacto + Iniciar perfil + Solo hacen falta unos pocos datos para empezar. + Introduce tu apodo para el chat + Género + Edad + País + Iniciar chat + Mujer + Hombre + Pareja + Transgénero (M->F) + Transgénero (F->M) + Buscar + Bandeja + Historial + Salir + en línea + conectando... + El nombre de usuario contiene + Desde la edad + Hasta la edad + País + Géneros + Todos + Buscar + Sin resultados. + La edad mínima no debe ser mayor que la edad máxima. + No hay chats sin leer. + %1$d nuevos + Atrás + Bloquear + Desbloquear + Imagen + Enviar + diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..341f90d --- /dev/null +++ b/android/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,41 @@ + + Entrer dans le chat + Compact, rapide et sans détour. Crée ton profil et commence tout de suite une conversation. + Chat mondial + Échange d’images + Utilisation compacte + Démarrer le profil + Quelques informations suffisent pour commencer. + Indique ton pseudo pour le chat + Genre + Âge + Pays + Démarrer le chat + Femme + Homme + Couple + Transgenre (M->F) + Transgenre (F->M) + Recherche + Boîte + Historique + Quitter + en ligne + connexion... + Le nom d’utilisateur contient + À partir de l’âge + Jusqu’à l’âge + Pays + Genres + Tous + Rechercher + Aucun résultat. + L’âge minimum ne doit pas être supérieur à l’âge maximum. + Aucun chat non lu. + %1$d nouveaux + Retour + Bloquer + Débloquer + Image + Envoyer + diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml new file mode 100644 index 0000000..64632ff --- /dev/null +++ b/android/app/src/main/res/values-it/strings.xml @@ -0,0 +1,41 @@ + + Vai direttamente alla chat + Compatto, veloce e senza passaggi inutili. Crea il tuo profilo e inizia subito una conversazione. + Chat mondiale + Scambio immagini + Comandi compatti + Avvia profilo + Bastano pochi dati per iniziare. + Inserisci il tuo nickname per la chat + Genere + Età + Paese + Avvia chat + Donna + Uomo + Coppia + Transgender (M->F) + Transgender (F->M) + Cerca + Posta + Cronologia + Esci + online + connessione... + Il nome utente contiene + Dall’età + Fino all’età + Paese + Generi + Tutti + Cerca + Nessun risultato. + L’età minima non deve essere maggiore dell’età massima. + Nessuna chat non letta. + %1$d nuovi + Indietro + Blocca + Sblocca + Immagine + Invia + diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..511b0ea --- /dev/null +++ b/android/app/src/main/res/values-ja/strings.xml @@ -0,0 +1,41 @@ + + チャットへ直接 + 手早く簡単にプロフィールを作成して、すぐに会話を始められます。 + 世界中のチャット + 画像交換 + シンプル操作 + プロフィールを開始 + 開始には少しの情報だけで十分です。 + チャット用ニックネーム + 性別 + 年齢 + + チャット開始 + 女性 + 男性 + カップル + トランスジェンダー (M->F) + トランスジェンダー (F->M) + 検索 + 受信箱 + 履歴 + 退出 + オンライン + 接続中... + ユーザー名に含まれる + 年齢から + 年齢まで + + 性別 + すべて + 検索 + 結果がありません。 + 最小年齢は最大年齢以下でなければなりません。 + 未読チャットはありません。 + %1$d 件の新着 + 戻る + ブロック + ブロック解除 + 画像 + 送信 + diff --git a/android/app/src/main/res/values-th/strings.xml b/android/app/src/main/res/values-th/strings.xml new file mode 100644 index 0000000..c340b93 --- /dev/null +++ b/android/app/src/main/res/values-th/strings.xml @@ -0,0 +1,41 @@ + + เข้าสู่แชททันที + รวดเร็ว กระชับ และไม่ซับซ้อน สร้างโปรไฟล์แล้วเริ่มคุยได้ทันที + แชททั่วโลก + แลกเปลี่ยนรูปภาพ + ใช้งานง่าย + เริ่มโปรไฟล์ + กรอกข้อมูลเพียงไม่กี่อย่างก็เริ่มได้ + ชื่อเล่นสำหรับแชท + เพศ + อายุ + ประเทศ + เริ่มแชท + หญิง + ชาย + คู่ + ข้ามเพศ (ชาย->หญิง) + ข้ามเพศ (หญิง->ชาย) + ค้นหา + กล่องข้อความ + ประวัติ + ออก + ออนไลน์ + กำลังเชื่อมต่อ... + ชื่อผู้ใช้มีคำว่า + จากอายุ + ถึงอายุ + ประเทศ + เพศ + ทั้งหมด + ค้นหา + ไม่มีผลลัพธ์ + อายุขั้นต่ำต้องไม่มากกว่าอายุสูงสุด + ไม่มีแชทที่ยังไม่ได้อ่าน + ใหม่ %1$d + กลับ + บล็อก + เลิกบล็อก + รูปภาพ + ส่ง + diff --git a/android/app/src/main/res/values-tl/strings.xml b/android/app/src/main/res/values-tl/strings.xml new file mode 100644 index 0000000..daf4b05 --- /dev/null +++ b/android/app/src/main/res/values-tl/strings.xml @@ -0,0 +1,41 @@ + + Diretso sa chat + Mabilis, simple at walang paligoy-ligoy. Gumawa ng profile at magsimula agad ng usapan. + Pandaigdigang chat + Palitan ng larawan + Madaling gamitin + Simulan ang profile + Ilang detalye lang ang kailangan para makapagsimula. + Ilagay ang iyong palayaw sa chat + Kasarian + Edad + Bansa + Simulan ang chat + Babae + Lalaki + Magkapareha + Transgender (M->F) + Transgender (F->M) + Maghanap + Inbox + Kasaysayan + Umalis + online + kumokonekta... + Kasama sa username + Mula sa edad + Hanggang edad + Bansa + Kasarian + Lahat + Maghanap + Walang resulta. + Ang minimum na edad ay hindi dapat mas mataas kaysa maximum na edad. + Walang hindi pa nababasang chat. + %1$d bago + Bumalik + I-block + I-unblock + Larawan + Ipadala + diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..a9cb74a --- /dev/null +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,41 @@ + + 直接进入聊天 + 简洁、快速、无需复杂步骤。创建个人资料并立即开始聊天。 + 全球聊天 + 图片交换 + 简洁操作 + 开始个人资料 + 只需少量信息即可开始。 + 请输入聊天昵称 + 性别 + 年龄 + 国家 + 开始聊天 + 女性 + 男性 + 情侣 + 跨性别 (男->女) + 跨性别 (女->男) + 搜索 + 收件箱 + 历史 + 离开 + 在线 + 正在连接... + 用户名包含 + 从年龄 + 到年龄 + 国家 + 性别 + 全部 + 搜索 + 没有结果。 + 最小年龄不能大于最大年龄。 + 没有未读聊天。 + %1$d 条新消息 + 返回 + 屏蔽 + 取消屏蔽 + 图片 + 发送 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..e564ad9 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,103 @@ + + YPChat + SingleChat + Directly into chat + Compact, fast and without detours. Create your profile and start a conversation right away. + Worldwide chat + Image exchange + Compact controls + Start profile + A few details are enough to get started. + Please enter your chat nickname + Gender + Age + Country + Start chat + Female + Male + Couple + Transgender (M->F) + Transgender (F->M) + Socket connected + Connecting socket... + online + connecting... + Online + Search + Inbox + History + Console + More + Logout + Timeout in %1$s + No other users online yet. + Username contains + From age + To age + Country + Genders + All + Search + No results. + Minimum age must not be greater than maximum age. + No unread chats. + %1$d new + No history yet. + No message + Back + Block + Unblock + Message + Image + Send + Smileys + Image message + Uploading image... + Image uploaded. + Image upload failed. + Image is larger than 5 MB. + Image could not be opened. + Received %1$s + + Country list could not be loaded: %1$s + %1$s has been blocked + %1$s has been unblocked + Feedback + Comment + Send feedback + Feedback saved. + No feedback yet. + Anonymous + Admin user + Password + Admin login + Logout admin + Delete + Console + Enter /command or admin login input + Send + No console output yet. + More + Feedback + Partners + FAQ + Rules + Safety + Imprint + Back to overview + Recommended and friendly projects for our community. + Answers to common questions about the chat. + Basic rules for respectful chatting. + Tips for privacy and safer usage. + Legal notice and contact details. + External link + Frequently Asked Questions + Chat Rules + Safety and Privacy + Imprint + Partners + Choose a nickname, enter your profile details and start chatting. Do not share sensitive data like phone numbers, addresses, passwords or payment information. You can send images, block users and use feedback for serious issues. + No insults, hate speech, illegal content, spam or unwanted harassment. Only send images you are allowed to share and respect the privacy of others. + Use a nickname that does not identify you. Do not share private contact or payment data. Be careful with links from strangers and end conversations that feel wrong. Use block and feedback for serious incidents. + Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Contact: tsschulz@tsschulz.de. External links are the responsibility of their operators. + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..2d6ed1d --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..ce41669 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "9.1.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.20" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..041b8fc --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,11 @@ +org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.dependency.excludeLibraryComponentsFromConstraints=true +android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false +kotlin.daemon.jvmargs=-Xmx2048m +android.lint.workerProcessMaxHeapSize=4g +lint.runInProcess=true diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..5c34300 --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..ef07e01 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/key.properties.example b/android/key.properties.example new file mode 100644 index 0000000..13758fe --- /dev/null +++ b/android/key.properties.example @@ -0,0 +1,4 @@ +storeFile=release-upload-key.jks +storePassword=CHANGE_ME +keyAlias=upload +keyPassword=CHANGE_ME diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a9f30fb --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "YpChatAndroid" +include(":app") diff --git a/client/src/components/ImprintContainer.vue b/client/src/components/ImprintContainer.vue index 6361b49..a1cf4a9 100644 --- a/client/src/components/ImprintContainer.vue +++ b/client/src/components/ImprintContainer.vue @@ -4,6 +4,7 @@ FAQ Regeln Sicherheit + Datenschutz Feedback Impressum diff --git a/client/src/router/index.js b/client/src/router/index.js index 2dc7d07..1543d5d 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -4,6 +4,7 @@ import PartnersView from '../views/PartnersView.vue'; import MockupView from '../views/MockupView.vue'; import FeedbackView from '../views/FeedbackView.vue'; import FaqView from '../views/FaqView.vue'; +import PrivacyView from '../views/PrivacyView.vue'; import RulesView from '../views/RulesView.vue'; import SafetyView from '../views/SafetyView.vue'; @@ -137,6 +138,20 @@ const safetySchema = { inLanguage: 'de-DE' }; +const privacySchema = { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'Datenschutz - SingleChat', + url: `${SITE_URL}/datenschutz`, + description: 'Datenschutzerklärung für SingleChat und die Android-App mit Informationen zu Profilangaben, Nachrichten, Bildern und Sitzungsdaten.', + isPartOf: { + '@type': 'WebSite', + name: 'SingleChat', + url: `${SITE_URL}/` + }, + inLanguage: 'de-DE' +}; + const routes = [ { path: '/', @@ -234,6 +249,22 @@ const routes = [ schema: safetySchema } }, + { + path: '/datenschutz', + name: 'datenschutz', + component: PrivacyView, + meta: { + title: 'Datenschutzerklärung für Website und App - SingleChat', + description: 'Datenschutzerklärung für SingleChat und die Android-App mit Informationen zu Profilangaben, Nachrichten, Bildern und Sitzungsdaten.', + keywords: 'datenschutz singlechat, privacy policy chat app, chat datenschutz, android app datenschutz', + ogTitle: 'Datenschutzerklärung für Website und App - SingleChat', + ogDescription: 'Informationen zur Datenverarbeitung bei SingleChat und in der Android-App.', + ogType: 'website', + image: DEFAULT_IMAGE, + robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1', + schema: privacySchema + } + }, { path: '/mockup-redesign', name: 'mockup-redesign', diff --git a/client/src/views/PrivacyView.vue b/client/src/views/PrivacyView.vue new file mode 100644 index 0000000..ed2faae --- /dev/null +++ b/client/src/views/PrivacyView.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/client/src/views/SafetyView.vue b/client/src/views/SafetyView.vue index d631470..dc90af4 100644 --- a/client/src/views/SafetyView.vue +++ b/client/src/views/SafetyView.vue @@ -35,8 +35,8 @@

Datenschutz

- Details findest du im Impressum/Datenschutz-Hinweis unten auf der Seite. Wenn du Fragen hast, kontaktiere uns - gern über Feedback. Für den respektvollen Umgang im Chat beachte zusätzlich unsere + Details findest du auf der Seite Datenschutz. Wenn du Fragen hast, + kontaktiere uns gern über Feedback. Für den respektvollen Umgang im Chat beachte zusätzlich unsere Chat-Regeln.

@@ -77,4 +77,3 @@ import ImprintContainer from '../components/ImprintContainer.vue'; text-decoration: none; } - diff --git a/package.json b/package.json index 0ecc941..3196ac8 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"", "install:all": "npm install && cd client && npm install", "build": "cd client && npm run build", + "i18n:android": "node scripts/generate-android-i18n.mjs", "start": "NODE_ENV=production node server/index.js", "start:prod": "NODE_ENV=production PORT=4000 node server/index.js" }, @@ -26,4 +27,3 @@ "concurrently": "^8.2.2" } } - diff --git a/scripts/generate-android-i18n.mjs b/scripts/generate-android-i18n.mjs new file mode 100644 index 0000000..649d1c5 --- /dev/null +++ b/scripts/generate-android-i18n.mjs @@ -0,0 +1,79 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; + +const root = process.cwd(); +const localesDir = path.join(root, "client", "src", "i18n", "locales"); +const androidResDir = path.join(root, "android", "app", "src", "main", "res"); + +const localeMap = { + en: "values", + de: "values-de", + es: "values-es", + fr: "values-fr", + it: "values-it", + ja: "values-ja", + th: "values-th", + tl: "values-tl", + zh: "values-zh-rCN" +}; + +const keyMap = { + label_nick: "label_nick", + label_gender: "label_gender", + label_age: "label_age", + label_country: "label_country", + button_start_chat: "button_start_chat", + gender_female: "gender_female", + gender_male: "gender_male", + gender_pair: "gender_pair", + gender_trans_mf: "gender_trans_mf", + gender_trans_fm: "gender_trans_fm", + menu_search: "tab_search", + menu_inbox: "tab_inbox", + menu_history: "tab_history", + button_send: "button_send", + search_username_includes: "search_username_includes", + search_from_age: "search_from_age", + search_to_age: "search_to_age", + search_country: "search_country", + search_genders: "search_genders", + search_all: "search_all", + search_button: "search_button", + search_no_results: "search_no_results", + search_min_age_error: "search_min_age_error", + history_empty: "history_empty", + button_block_user: "block", + button_unblock_user: "unblock", + tooltip_send_image: "button_image" +}; + +function xmlEscape(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, '\\"') + .replace(/'/g, "\\'"); +} + +for (const [locale, folder] of Object.entries(localeMap)) { + const sourcePath = path.join(localesDir, `${locale}.json`); + const targetDir = path.join(androidResDir, folder); + const targetPath = path.join(targetDir, "strings.generated.xml"); + + const raw = await readFile(sourcePath, "utf8"); + const json = JSON.parse(raw); + const lines = [""]; + + for (const [webKey, androidKey] of Object.entries(keyMap)) { + const value = json[webKey]; + if (!value || typeof value !== "string") continue; + lines.push(` ${xmlEscape(value.replace(/<[^>]+>/g, "").trim())}`); + } + + lines.push("", ""); + await mkdir(targetDir, { recursive: true }); + await writeFile(targetPath, lines.join("\n"), "utf8"); +} + +console.log("Android i18n files generated from web locales."); diff --git a/server/index.js b/server/index.js index 5d4170b..3281caf 100644 --- a/server/index.js +++ b/server/index.js @@ -51,7 +51,7 @@ app.use(cors({ origin: (origin, callback) => { // Erlaube Requests ohne Origin (z.B. Postman, mobile Apps) if (!origin) return callback(null, true); - + if (allowedOrigins.includes(origin) || !IS_PRODUCTION) { callback(null, true); } else { @@ -174,7 +174,7 @@ setupBroadcast(io, __dirname); // SPA-Fallback muss nach allen anderen Routen stehen if (IS_PRODUCTION) { const distPath = join(__dirname, '../docroot/dist'); - const KNOWN_SPA_ROUTES = new Set(['/', '/partners', '/feedback', '/faq', '/regeln', '/sicherheit', '/mockup-redesign']); + const KNOWN_SPA_ROUTES = new Set(['/', '/partners', '/feedback', '/faq', '/regeln', '/sicherheit', '/datenschutz', '/mockup-redesign']); app.use(express.static(distPath)); // Fallback für Vue Router (SPA) - muss am Ende stehen app.get('*', (req, res) => { @@ -202,7 +202,10 @@ if (IS_PRODUCTION) { } // Server starten -const HOST = '127.0.0.1'; // Nur localhost, da Apache als Reverse Proxy fungiert +// In Production laeuft Apache als Reverse Proxy auf localhost. +// In Development kann HOST=0.0.0.0 gesetzt werden, damit echte Android-Geraete +// im selben WLAN den lokalen Server erreichen. +const HOST = process.env.HOST || '127.0.0.1'; server.listen(PORT, HOST, () => { console.log(`Server läuft auf http://${HOST}:${PORT}`); @@ -212,4 +215,3 @@ server.listen(PORT, HOST, () => { console.log('Production-Modus: HTTPS über Apache Reverse Proxy'); } }); - diff --git a/server/routes-seo.js b/server/routes-seo.js index 68e81d4..44d88a2 100644 --- a/server/routes-seo.js +++ b/server/routes-seo.js @@ -202,6 +202,30 @@ const seoData = { }, inLanguage: 'de-DE' } + }, + '/datenschutz': { + title: 'Datenschutzerklärung für Website und App - SingleChat', + description: 'Datenschutzerklärung für SingleChat und die Android-App mit Informationen zu Profilangaben, Nachrichten, Bildern und Sitzungsdaten.', + keywords: 'datenschutz singlechat, privacy policy chat app, chat datenschutz, android app datenschutz', + ogTitle: 'Datenschutzerklärung für Website und App - SingleChat', + ogDescription: 'Informationen zur Datenverarbeitung bei SingleChat und in der Android-App.', + ogType: 'website', + ogUrl: `${SITE_URL}/datenschutz`, + ogImage: DEFAULT_IMAGE, + robots: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1', + schema: { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'Datenschutz - SingleChat', + url: `${SITE_URL}/datenschutz`, + description: 'Datenschutzerklärung für SingleChat und die Android-App mit Informationen zu Profilangaben, Nachrichten, Bildern und Sitzungsdaten.', + isPartOf: { + '@type': 'WebSite', + name: 'SingleChat', + url: `${SITE_URL}/` + }, + inLanguage: 'de-DE' + } } }; @@ -449,6 +473,7 @@ Allow: /feedback Allow: /faq Allow: /regeln Allow: /sicherheit +Allow: /datenschutz Disallow: /api/ Disallow: /static/logs/ Disallow: /mockup-redesign