android version
This commit is contained in:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -7,6 +7,18 @@ logs/chat-users.json
|
|||||||
client/dist/
|
client/dist/
|
||||||
dist/
|
dist/
|
||||||
docroot/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)
|
# Temporäre Bilder (werden nach 6 Stunden automatisch gelöscht)
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
396
ANDROID-APP-KONZEPT.md
Normal file
396
ANDROID-APP-KONZEPT.md
Normal file
@@ -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": "<sessionId aus /api/session>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<ChatUiState>` 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": "<code>",
|
||||||
|
"url": "/api/image/<code>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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/<code>`.
|
||||||
|
|
||||||
|
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.
|
||||||
209
android/DATA_SAFETY_DRAFT.md
Normal file
209
android/DATA_SAFETY_DRAFT.md
Normal file
@@ -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
|
||||||
64
android/PLAY_CONSOLE_CHECKLIST.md
Normal file
64
android/PLAY_CONSOLE_CHECKLIST.md
Normal file
@@ -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
|
||||||
120
android/PLAY_STORE_LISTING.md
Normal file
120
android/PLAY_STORE_LISTING.md
Normal file
@@ -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.`
|
||||||
161
android/PUBLISHING.md
Normal file
161
android/PUBLISHING.md
Normal file
@@ -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
|
||||||
71
android/README.md
Normal file
71
android/README.md
Normal file
@@ -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
|
||||||
50
android/RELEASE_UPLOAD_CHECKLIST.md
Normal file
50
android/RELEASE_UPLOAD_CHECKLIST.md
Normal file
@@ -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
|
||||||
118
android/app/build.gradle.kts
Normal file
118
android/app/build.gradle.kts
Normal file
@@ -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")
|
||||||
|
}
|
||||||
6
android/app/proguard-rules.pro
vendored
Normal file
6
android/app/proguard-rules.pro
vendored
Normal file
@@ -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.**
|
||||||
23
android/app/src/main/AndroidManifest.xml
Normal file
23
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".YpChatApp"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@drawable/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@drawable/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.YpChat">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|
||||||
20
android/app/src/main/java/net/ypchat/app/MainActivity.kt
Normal file
20
android/app/src/main/java/net/ypchat/app/MainActivity.kt
Normal file
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
14
android/app/src/main/java/net/ypchat/app/YpChatApp.kt
Normal file
14
android/app/src/main/java/net/ypchat/app/YpChatApp.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package net.ypchat.app.core
|
||||||
|
|
||||||
|
import net.ypchat.app.BuildConfig
|
||||||
|
|
||||||
|
object AppConfig {
|
||||||
|
val baseUrl: String = BuildConfig.BASE_URL.trimEnd('/')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Cookie>) {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
cookies.forEach { cookie ->
|
||||||
|
editor.putString(cookie.name, cookie.toString())
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
54
android/app/src/main/java/net/ypchat/app/data/api/RestApi.kt
Normal file
54
android/app/src/main/java/net/ypchat/app/data/api/RestApi.kt
Normal file
@@ -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<PartnerLinkDto>
|
||||||
|
|
||||||
|
@Multipart
|
||||||
|
@POST("api/upload-image")
|
||||||
|
suspend fun uploadImage(@Part image: MultipartBody.Part): Response<ImageUploadResponse>
|
||||||
|
}
|
||||||
@@ -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<SocketEvent>(extraBufferCapacity = 64)
|
||||||
|
val events: SharedFlow<SocketEvent> = _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<String>, genders: List<String>) {
|
||||||
|
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<out Any>.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<String> {
|
||||||
|
if (this == null) return emptyList()
|
||||||
|
return List(length()) { index -> opt(index)?.toString().orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONArray?.toNestedStringList(): List<List<String>> {
|
||||||
|
if (this == null) return emptyList()
|
||||||
|
return List(length()) { index -> optJSONArray(index).toStringList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> JSONArray.toObjectList(mapper: (JSONObject) -> T): List<T> = buildList {
|
||||||
|
for (index in 0 until length()) {
|
||||||
|
optJSONObject(index)?.let { add(mapper(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FeedbackItemDto> = 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<String, String>()
|
||||||
@@ -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<UserDto>) : 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<ChatMessageDto>) : SocketEvent
|
||||||
|
data class SearchResults(val results: List<UserDto>) : SocketEvent
|
||||||
|
data class HistoryResults(val results: List<HistoryItemDto>) : SocketEvent
|
||||||
|
data class InboxResults(val results: List<InboxItemDto>) : 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<String>, val kind: String) : SocketEvent
|
||||||
|
data class CommandTable(val title: String, val columns: List<String>, val rows: List<List<String>>) : SocketEvent
|
||||||
|
data class Error(val message: String) : SocketEvent
|
||||||
|
data class ConnectionChanged(val connected: Boolean, val reason: String? = null) : SocketEvent
|
||||||
|
}
|
||||||
@@ -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<ChatState> = _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<String>, genders: List<String>) {
|
||||||
|
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<String>,
|
||||||
|
val rows: List<List<String>>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatState(
|
||||||
|
val isConnected: Boolean = false,
|
||||||
|
val isLoggedIn: Boolean = false,
|
||||||
|
val expressSessionId: String? = null,
|
||||||
|
val currentUser: UserDto? = null,
|
||||||
|
val users: List<UserDto> = emptyList(),
|
||||||
|
val currentConversation: String? = null,
|
||||||
|
val messages: List<ChatMessageDto> = emptyList(),
|
||||||
|
val searchResults: List<UserDto> = emptyList(),
|
||||||
|
val inboxResults: List<InboxItemDto> = emptyList(),
|
||||||
|
val historyResults: List<HistoryItemDto> = emptyList(),
|
||||||
|
val countries: List<CountryOption> = emptyList(),
|
||||||
|
val feedbackItems: List<FeedbackItemDto> = emptyList(),
|
||||||
|
val feedbackMessage: String? = null,
|
||||||
|
val feedbackAdminAuthenticated: Boolean = false,
|
||||||
|
val feedbackAdminUserName: String? = null,
|
||||||
|
val feedbackAdminError: String? = null,
|
||||||
|
val partnerLinks: List<PartnerLinkDto> = emptyList(),
|
||||||
|
val partnersError: String? = null,
|
||||||
|
val savedProfile: SavedProfile = SavedProfile(),
|
||||||
|
val commandLines: List<String> = 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
|
||||||
|
)
|
||||||
124
android/app/src/main/java/net/ypchat/app/ui/ChatViewModel.kt
Normal file
124
android/app/src/main/java/net/ypchat/app/ui/ChatViewModel.kt
Normal file
@@ -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<ChatState> = 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<String>, genders: List<String>) {
|
||||||
|
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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return ChatViewModel(repository) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
1039
android/app/src/main/java/net/ypchat/app/ui/YpChatRoot.kt
Normal file
1039
android/app/src/main/java/net/ypchat/app/ui/YpChatRoot.kt
Normal file
File diff suppressed because it is too large
Load Diff
BIN
android/app/src/main/res/drawable-nodpi/app_icon_asset.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/app_icon_asset.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 985 KiB |
BIN
android/app/src/main/res/drawable-nodpi/image_button.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/image_button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
android/app/src/main/res/drawable-nodpi/smileys_button.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/smileys_button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
3
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
3
android/app/src/main/res/drawable/ic_launcher.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@drawable/app_icon_asset" />
|
||||||
103
android/app/src/main/res/values-de/strings.xml
Normal file
103
android/app/src/main/res/values-de/strings.xml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">YPChat</string>
|
||||||
|
<string name="landing_eyebrow">SingleChat</string>
|
||||||
|
<string name="landing_title">Direkt in den Chat</string>
|
||||||
|
<string name="landing_copy">Kompakt, schnell und ohne Umwege. Erstelle dein Profil und starte sofort eine Unterhaltung.</string>
|
||||||
|
<string name="feature_worldwide_chat">Weltweiter Chat</string>
|
||||||
|
<string name="feature_image_exchange">Bildaustausch</string>
|
||||||
|
<string name="feature_compact_controls">Kompakte Bedienung</string>
|
||||||
|
<string name="profile_title">Profil starten</string>
|
||||||
|
<string name="profile_copy">Wenige Angaben genügen für den Einstieg.</string>
|
||||||
|
<string name="label_nick">Bitte gib deinen Nicknamen für den Chat ein</string>
|
||||||
|
<string name="label_gender">Geschlecht</string>
|
||||||
|
<string name="label_age">Alter</string>
|
||||||
|
<string name="label_country">Land</string>
|
||||||
|
<string name="button_start_chat">Chat starten</string>
|
||||||
|
<string name="gender_female">Weiblich</string>
|
||||||
|
<string name="gender_male">Männlich</string>
|
||||||
|
<string name="gender_pair">Paar</string>
|
||||||
|
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||||
|
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||||
|
<string name="socket_connected">Socket verbunden</string>
|
||||||
|
<string name="socket_connecting">Socket wird verbunden...</string>
|
||||||
|
<string name="status_online">online</string>
|
||||||
|
<string name="status_connecting">verbindet...</string>
|
||||||
|
<string name="tab_online">Online</string>
|
||||||
|
<string name="tab_search">Suche</string>
|
||||||
|
<string name="tab_inbox">Posteingang</string>
|
||||||
|
<string name="tab_history">Verlauf</string>
|
||||||
|
<string name="tab_console">Konsole</string>
|
||||||
|
<string name="tab_more">Mehr</string>
|
||||||
|
<string name="logout">Verlassen</string>
|
||||||
|
<string name="timeout_in">Timeout in %1$s</string>
|
||||||
|
<string name="no_users_online">Noch keine anderen Nutzer online.</string>
|
||||||
|
<string name="search_username_includes">Benutzername enthält</string>
|
||||||
|
<string name="search_from_age">Von Alter</string>
|
||||||
|
<string name="search_to_age">Bis Alter</string>
|
||||||
|
<string name="search_country">Land</string>
|
||||||
|
<string name="search_genders">Geschlechter</string>
|
||||||
|
<string name="search_all">Alle</string>
|
||||||
|
<string name="search_button">Suchen</string>
|
||||||
|
<string name="search_no_results">Keine Ergebnisse.</string>
|
||||||
|
<string name="search_min_age_error">Das Mindestalter darf nicht größer als das Höchstalter sein.</string>
|
||||||
|
<string name="inbox_empty">Keine ungelesenen Chats.</string>
|
||||||
|
<string name="inbox_new_count">%1$d neu</string>
|
||||||
|
<string name="history_empty">Noch kein Verlauf.</string>
|
||||||
|
<string name="no_message">Keine Nachricht</string>
|
||||||
|
<string name="back">Zurück</string>
|
||||||
|
<string name="block">Blockieren</string>
|
||||||
|
<string name="unblock">Entsperren</string>
|
||||||
|
<string name="message_placeholder">Nachricht</string>
|
||||||
|
<string name="button_image">Bild</string>
|
||||||
|
<string name="button_send">Senden</string>
|
||||||
|
<string name="button_smileys">Smileys</string>
|
||||||
|
<string name="image_message">Bildnachricht</string>
|
||||||
|
<string name="image_upload_in_progress">Bild wird hochgeladen...</string>
|
||||||
|
<string name="image_upload_success">Bild wurde hochgeladen.</string>
|
||||||
|
<string name="image_upload_failed">Bild-Upload fehlgeschlagen.</string>
|
||||||
|
<string name="image_upload_too_large">Das Bild ist größer als 5 MB.</string>
|
||||||
|
<string name="image_upload_open_failed">Das Bild konnte nicht geöffnet werden.</string>
|
||||||
|
<string name="feedback_created_at">Eingegangen %1$s</string>
|
||||||
|
<string name="feedback_meta_separator"> • </string>
|
||||||
|
<string name="countries_load_error">Länderliste konnte nicht geladen werden: %1$s</string>
|
||||||
|
<string name="user_blocked">%1$s wurde blockiert</string>
|
||||||
|
<string name="user_unblocked">%1$s wurde entsperrt</string>
|
||||||
|
<string name="feedback_title">Feedback</string>
|
||||||
|
<string name="feedback_comment">Kommentar</string>
|
||||||
|
<string name="feedback_send">Feedback senden</string>
|
||||||
|
<string name="feedback_saved">Feedback wurde gespeichert.</string>
|
||||||
|
<string name="feedback_empty">Noch kein Feedback vorhanden.</string>
|
||||||
|
<string name="anonymous">Anonym</string>
|
||||||
|
<string name="feedback_admin_user">Admin-Benutzer</string>
|
||||||
|
<string name="feedback_admin_password">Passwort</string>
|
||||||
|
<string name="feedback_admin_login">Admin-Login</string>
|
||||||
|
<string name="feedback_admin_logout">Admin abmelden</string>
|
||||||
|
<string name="feedback_delete">Löschen</string>
|
||||||
|
<string name="console_title">Konsole</string>
|
||||||
|
<string name="console_placeholder">/Befehl oder Admin-Login-Eingabe senden</string>
|
||||||
|
<string name="console_send">Senden</string>
|
||||||
|
<string name="console_empty">Noch keine Konsolen-Ausgabe.</string>
|
||||||
|
<string name="more_title">Mehr</string>
|
||||||
|
<string name="more_feedback">Feedback</string>
|
||||||
|
<string name="more_partners">Partner</string>
|
||||||
|
<string name="more_faq">FAQ</string>
|
||||||
|
<string name="more_rules">Regeln</string>
|
||||||
|
<string name="more_safety">Sicherheit</string>
|
||||||
|
<string name="more_imprint">Impressum</string>
|
||||||
|
<string name="more_back">Zur Übersicht</string>
|
||||||
|
<string name="partners_intro">Empfehlungen und befreundete Projekte für unsere Community.</string>
|
||||||
|
<string name="faq_intro">Antworten auf häufige Fragen zum Chat.</string>
|
||||||
|
<string name="rules_intro">Grundregeln für respektvollen Chat.</string>
|
||||||
|
<string name="safety_intro">Tipps für Privatsphäre und sichere Nutzung.</string>
|
||||||
|
<string name="imprint_intro">Rechtliche Hinweise und Kontaktdaten.</string>
|
||||||
|
<string name="external_link">Externer Link</string>
|
||||||
|
<string name="faq_title">Häufige Fragen</string>
|
||||||
|
<string name="rules_title">Chat-Regeln</string>
|
||||||
|
<string name="safety_title">Sicherheit und Privatsphäre</string>
|
||||||
|
<string name="imprint_title">Impressum</string>
|
||||||
|
<string name="partners_title">Partner</string>
|
||||||
|
<string name="faq_body">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.</string>
|
||||||
|
<string name="rules_body">Keine Beleidigungen, Hassrede, illegalen Inhalte, Spam oder unerwünschte Belästigung. Sende nur Bilder, die du teilen darfst, und respektiere die Privatsphäre anderer.</string>
|
||||||
|
<string name="safety_body">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.</string>
|
||||||
|
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Kontakt: tsschulz@tsschulz.de. Für externe Links sind deren Betreiber verantwortlich.</string>
|
||||||
|
</resources>
|
||||||
41
android/app/src/main/res/values-es/strings.xml
Normal file
41
android/app/src/main/res/values-es/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="landing_title">Entrar al chat</string>
|
||||||
|
<string name="landing_copy">Compacto, rápido y sin rodeos. Crea tu perfil y empieza una conversación al instante.</string>
|
||||||
|
<string name="feature_worldwide_chat">Chat mundial</string>
|
||||||
|
<string name="feature_image_exchange">Intercambio de imágenes</string>
|
||||||
|
<string name="feature_compact_controls">Uso compacto</string>
|
||||||
|
<string name="profile_title">Iniciar perfil</string>
|
||||||
|
<string name="profile_copy">Solo hacen falta unos pocos datos para empezar.</string>
|
||||||
|
<string name="label_nick">Introduce tu apodo para el chat</string>
|
||||||
|
<string name="label_gender">Género</string>
|
||||||
|
<string name="label_age">Edad</string>
|
||||||
|
<string name="label_country">País</string>
|
||||||
|
<string name="button_start_chat">Iniciar chat</string>
|
||||||
|
<string name="gender_female">Mujer</string>
|
||||||
|
<string name="gender_male">Hombre</string>
|
||||||
|
<string name="gender_pair">Pareja</string>
|
||||||
|
<string name="gender_trans_mf">Transgénero (M->F)</string>
|
||||||
|
<string name="gender_trans_fm">Transgénero (F->M)</string>
|
||||||
|
<string name="tab_search">Buscar</string>
|
||||||
|
<string name="tab_inbox">Bandeja</string>
|
||||||
|
<string name="tab_history">Historial</string>
|
||||||
|
<string name="logout">Salir</string>
|
||||||
|
<string name="status_online">en línea</string>
|
||||||
|
<string name="status_connecting">conectando...</string>
|
||||||
|
<string name="search_username_includes">El nombre de usuario contiene</string>
|
||||||
|
<string name="search_from_age">Desde la edad</string>
|
||||||
|
<string name="search_to_age">Hasta la edad</string>
|
||||||
|
<string name="search_country">País</string>
|
||||||
|
<string name="search_genders">Géneros</string>
|
||||||
|
<string name="search_all">Todos</string>
|
||||||
|
<string name="search_button">Buscar</string>
|
||||||
|
<string name="search_no_results">Sin resultados.</string>
|
||||||
|
<string name="search_min_age_error">La edad mínima no debe ser mayor que la edad máxima.</string>
|
||||||
|
<string name="inbox_empty">No hay chats sin leer.</string>
|
||||||
|
<string name="inbox_new_count">%1$d nuevos</string>
|
||||||
|
<string name="back">Atrás</string>
|
||||||
|
<string name="block">Bloquear</string>
|
||||||
|
<string name="unblock">Desbloquear</string>
|
||||||
|
<string name="button_image">Imagen</string>
|
||||||
|
<string name="button_send">Enviar</string>
|
||||||
|
</resources>
|
||||||
41
android/app/src/main/res/values-fr/strings.xml
Normal file
41
android/app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="landing_title">Entrer dans le chat</string>
|
||||||
|
<string name="landing_copy">Compact, rapide et sans détour. Crée ton profil et commence tout de suite une conversation.</string>
|
||||||
|
<string name="feature_worldwide_chat">Chat mondial</string>
|
||||||
|
<string name="feature_image_exchange">Échange d’images</string>
|
||||||
|
<string name="feature_compact_controls">Utilisation compacte</string>
|
||||||
|
<string name="profile_title">Démarrer le profil</string>
|
||||||
|
<string name="profile_copy">Quelques informations suffisent pour commencer.</string>
|
||||||
|
<string name="label_nick">Indique ton pseudo pour le chat</string>
|
||||||
|
<string name="label_gender">Genre</string>
|
||||||
|
<string name="label_age">Âge</string>
|
||||||
|
<string name="label_country">Pays</string>
|
||||||
|
<string name="button_start_chat">Démarrer le chat</string>
|
||||||
|
<string name="gender_female">Femme</string>
|
||||||
|
<string name="gender_male">Homme</string>
|
||||||
|
<string name="gender_pair">Couple</string>
|
||||||
|
<string name="gender_trans_mf">Transgenre (M->F)</string>
|
||||||
|
<string name="gender_trans_fm">Transgenre (F->M)</string>
|
||||||
|
<string name="tab_search">Recherche</string>
|
||||||
|
<string name="tab_inbox">Boîte</string>
|
||||||
|
<string name="tab_history">Historique</string>
|
||||||
|
<string name="logout">Quitter</string>
|
||||||
|
<string name="status_online">en ligne</string>
|
||||||
|
<string name="status_connecting">connexion...</string>
|
||||||
|
<string name="search_username_includes">Le nom d’utilisateur contient</string>
|
||||||
|
<string name="search_from_age">À partir de l’âge</string>
|
||||||
|
<string name="search_to_age">Jusqu’à l’âge</string>
|
||||||
|
<string name="search_country">Pays</string>
|
||||||
|
<string name="search_genders">Genres</string>
|
||||||
|
<string name="search_all">Tous</string>
|
||||||
|
<string name="search_button">Rechercher</string>
|
||||||
|
<string name="search_no_results">Aucun résultat.</string>
|
||||||
|
<string name="search_min_age_error">L’âge minimum ne doit pas être supérieur à l’âge maximum.</string>
|
||||||
|
<string name="inbox_empty">Aucun chat non lu.</string>
|
||||||
|
<string name="inbox_new_count">%1$d nouveaux</string>
|
||||||
|
<string name="back">Retour</string>
|
||||||
|
<string name="block">Bloquer</string>
|
||||||
|
<string name="unblock">Débloquer</string>
|
||||||
|
<string name="button_image">Image</string>
|
||||||
|
<string name="button_send">Envoyer</string>
|
||||||
|
</resources>
|
||||||
41
android/app/src/main/res/values-it/strings.xml
Normal file
41
android/app/src/main/res/values-it/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="landing_title">Vai direttamente alla chat</string>
|
||||||
|
<string name="landing_copy">Compatto, veloce e senza passaggi inutili. Crea il tuo profilo e inizia subito una conversazione.</string>
|
||||||
|
<string name="feature_worldwide_chat">Chat mondiale</string>
|
||||||
|
<string name="feature_image_exchange">Scambio immagini</string>
|
||||||
|
<string name="feature_compact_controls">Comandi compatti</string>
|
||||||
|
<string name="profile_title">Avvia profilo</string>
|
||||||
|
<string name="profile_copy">Bastano pochi dati per iniziare.</string>
|
||||||
|
<string name="label_nick">Inserisci il tuo nickname per la chat</string>
|
||||||
|
<string name="label_gender">Genere</string>
|
||||||
|
<string name="label_age">Età</string>
|
||||||
|
<string name="label_country">Paese</string>
|
||||||
|
<string name="button_start_chat">Avvia chat</string>
|
||||||
|
<string name="gender_female">Donna</string>
|
||||||
|
<string name="gender_male">Uomo</string>
|
||||||
|
<string name="gender_pair">Coppia</string>
|
||||||
|
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||||
|
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||||
|
<string name="tab_search">Cerca</string>
|
||||||
|
<string name="tab_inbox">Posta</string>
|
||||||
|
<string name="tab_history">Cronologia</string>
|
||||||
|
<string name="logout">Esci</string>
|
||||||
|
<string name="status_online">online</string>
|
||||||
|
<string name="status_connecting">connessione...</string>
|
||||||
|
<string name="search_username_includes">Il nome utente contiene</string>
|
||||||
|
<string name="search_from_age">Dall’età</string>
|
||||||
|
<string name="search_to_age">Fino all’età</string>
|
||||||
|
<string name="search_country">Paese</string>
|
||||||
|
<string name="search_genders">Generi</string>
|
||||||
|
<string name="search_all">Tutti</string>
|
||||||
|
<string name="search_button">Cerca</string>
|
||||||
|
<string name="search_no_results">Nessun risultato.</string>
|
||||||
|
<string name="search_min_age_error">L’età minima non deve essere maggiore dell’età massima.</string>
|
||||||
|
<string name="inbox_empty">Nessuna chat non letta.</string>
|
||||||
|
<string name="inbox_new_count">%1$d nuovi</string>
|
||||||
|
<string name="back">Indietro</string>
|
||||||
|
<string name="block">Blocca</string>
|
||||||
|
<string name="unblock">Sblocca</string>
|
||||||
|
<string name="button_image">Immagine</string>
|
||||||
|
<string name="button_send">Invia</string>
|
||||||
|
</resources>
|
||||||
41
android/app/src/main/res/values-ja/strings.xml
Normal file
41
android/app/src/main/res/values-ja/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="landing_title">チャットへ直接</string>
|
||||||
|
<string name="landing_copy">手早く簡単にプロフィールを作成して、すぐに会話を始められます。</string>
|
||||||
|
<string name="feature_worldwide_chat">世界中のチャット</string>
|
||||||
|
<string name="feature_image_exchange">画像交換</string>
|
||||||
|
<string name="feature_compact_controls">シンプル操作</string>
|
||||||
|
<string name="profile_title">プロフィールを開始</string>
|
||||||
|
<string name="profile_copy">開始には少しの情報だけで十分です。</string>
|
||||||
|
<string name="label_nick">チャット用ニックネーム</string>
|
||||||
|
<string name="label_gender">性別</string>
|
||||||
|
<string name="label_age">年齢</string>
|
||||||
|
<string name="label_country">国</string>
|
||||||
|
<string name="button_start_chat">チャット開始</string>
|
||||||
|
<string name="gender_female">女性</string>
|
||||||
|
<string name="gender_male">男性</string>
|
||||||
|
<string name="gender_pair">カップル</string>
|
||||||
|
<string name="gender_trans_mf">トランスジェンダー (M->F)</string>
|
||||||
|
<string name="gender_trans_fm">トランスジェンダー (F->M)</string>
|
||||||
|
<string name="tab_search">検索</string>
|
||||||
|
<string name="tab_inbox">受信箱</string>
|
||||||
|
<string name="tab_history">履歴</string>
|
||||||
|
<string name="logout">退出</string>
|
||||||
|
<string name="status_online">オンライン</string>
|
||||||
|
<string name="status_connecting">接続中...</string>
|
||||||
|
<string name="search_username_includes">ユーザー名に含まれる</string>
|
||||||
|
<string name="search_from_age">年齢から</string>
|
||||||
|
<string name="search_to_age">年齢まで</string>
|
||||||
|
<string name="search_country">国</string>
|
||||||
|
<string name="search_genders">性別</string>
|
||||||
|
<string name="search_all">すべて</string>
|
||||||
|
<string name="search_button">検索</string>
|
||||||
|
<string name="search_no_results">結果がありません。</string>
|
||||||
|
<string name="search_min_age_error">最小年齢は最大年齢以下でなければなりません。</string>
|
||||||
|
<string name="inbox_empty">未読チャットはありません。</string>
|
||||||
|
<string name="inbox_new_count">%1$d 件の新着</string>
|
||||||
|
<string name="back">戻る</string>
|
||||||
|
<string name="block">ブロック</string>
|
||||||
|
<string name="unblock">ブロック解除</string>
|
||||||
|
<string name="button_image">画像</string>
|
||||||
|
<string name="button_send">送信</string>
|
||||||
|
</resources>
|
||||||
41
android/app/src/main/res/values-th/strings.xml
Normal file
41
android/app/src/main/res/values-th/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="landing_title">เข้าสู่แชททันที</string>
|
||||||
|
<string name="landing_copy">รวดเร็ว กระชับ และไม่ซับซ้อน สร้างโปรไฟล์แล้วเริ่มคุยได้ทันที</string>
|
||||||
|
<string name="feature_worldwide_chat">แชททั่วโลก</string>
|
||||||
|
<string name="feature_image_exchange">แลกเปลี่ยนรูปภาพ</string>
|
||||||
|
<string name="feature_compact_controls">ใช้งานง่าย</string>
|
||||||
|
<string name="profile_title">เริ่มโปรไฟล์</string>
|
||||||
|
<string name="profile_copy">กรอกข้อมูลเพียงไม่กี่อย่างก็เริ่มได้</string>
|
||||||
|
<string name="label_nick">ชื่อเล่นสำหรับแชท</string>
|
||||||
|
<string name="label_gender">เพศ</string>
|
||||||
|
<string name="label_age">อายุ</string>
|
||||||
|
<string name="label_country">ประเทศ</string>
|
||||||
|
<string name="button_start_chat">เริ่มแชท</string>
|
||||||
|
<string name="gender_female">หญิง</string>
|
||||||
|
<string name="gender_male">ชาย</string>
|
||||||
|
<string name="gender_pair">คู่</string>
|
||||||
|
<string name="gender_trans_mf">ข้ามเพศ (ชาย->หญิง)</string>
|
||||||
|
<string name="gender_trans_fm">ข้ามเพศ (หญิง->ชาย)</string>
|
||||||
|
<string name="tab_search">ค้นหา</string>
|
||||||
|
<string name="tab_inbox">กล่องข้อความ</string>
|
||||||
|
<string name="tab_history">ประวัติ</string>
|
||||||
|
<string name="logout">ออก</string>
|
||||||
|
<string name="status_online">ออนไลน์</string>
|
||||||
|
<string name="status_connecting">กำลังเชื่อมต่อ...</string>
|
||||||
|
<string name="search_username_includes">ชื่อผู้ใช้มีคำว่า</string>
|
||||||
|
<string name="search_from_age">จากอายุ</string>
|
||||||
|
<string name="search_to_age">ถึงอายุ</string>
|
||||||
|
<string name="search_country">ประเทศ</string>
|
||||||
|
<string name="search_genders">เพศ</string>
|
||||||
|
<string name="search_all">ทั้งหมด</string>
|
||||||
|
<string name="search_button">ค้นหา</string>
|
||||||
|
<string name="search_no_results">ไม่มีผลลัพธ์</string>
|
||||||
|
<string name="search_min_age_error">อายุขั้นต่ำต้องไม่มากกว่าอายุสูงสุด</string>
|
||||||
|
<string name="inbox_empty">ไม่มีแชทที่ยังไม่ได้อ่าน</string>
|
||||||
|
<string name="inbox_new_count">ใหม่ %1$d</string>
|
||||||
|
<string name="back">กลับ</string>
|
||||||
|
<string name="block">บล็อก</string>
|
||||||
|
<string name="unblock">เลิกบล็อก</string>
|
||||||
|
<string name="button_image">รูปภาพ</string>
|
||||||
|
<string name="button_send">ส่ง</string>
|
||||||
|
</resources>
|
||||||
41
android/app/src/main/res/values-tl/strings.xml
Normal file
41
android/app/src/main/res/values-tl/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="landing_title">Diretso sa chat</string>
|
||||||
|
<string name="landing_copy">Mabilis, simple at walang paligoy-ligoy. Gumawa ng profile at magsimula agad ng usapan.</string>
|
||||||
|
<string name="feature_worldwide_chat">Pandaigdigang chat</string>
|
||||||
|
<string name="feature_image_exchange">Palitan ng larawan</string>
|
||||||
|
<string name="feature_compact_controls">Madaling gamitin</string>
|
||||||
|
<string name="profile_title">Simulan ang profile</string>
|
||||||
|
<string name="profile_copy">Ilang detalye lang ang kailangan para makapagsimula.</string>
|
||||||
|
<string name="label_nick">Ilagay ang iyong palayaw sa chat</string>
|
||||||
|
<string name="label_gender">Kasarian</string>
|
||||||
|
<string name="label_age">Edad</string>
|
||||||
|
<string name="label_country">Bansa</string>
|
||||||
|
<string name="button_start_chat">Simulan ang chat</string>
|
||||||
|
<string name="gender_female">Babae</string>
|
||||||
|
<string name="gender_male">Lalaki</string>
|
||||||
|
<string name="gender_pair">Magkapareha</string>
|
||||||
|
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||||
|
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||||
|
<string name="tab_search">Maghanap</string>
|
||||||
|
<string name="tab_inbox">Inbox</string>
|
||||||
|
<string name="tab_history">Kasaysayan</string>
|
||||||
|
<string name="logout">Umalis</string>
|
||||||
|
<string name="status_online">online</string>
|
||||||
|
<string name="status_connecting">kumokonekta...</string>
|
||||||
|
<string name="search_username_includes">Kasama sa username</string>
|
||||||
|
<string name="search_from_age">Mula sa edad</string>
|
||||||
|
<string name="search_to_age">Hanggang edad</string>
|
||||||
|
<string name="search_country">Bansa</string>
|
||||||
|
<string name="search_genders">Kasarian</string>
|
||||||
|
<string name="search_all">Lahat</string>
|
||||||
|
<string name="search_button">Maghanap</string>
|
||||||
|
<string name="search_no_results">Walang resulta.</string>
|
||||||
|
<string name="search_min_age_error">Ang minimum na edad ay hindi dapat mas mataas kaysa maximum na edad.</string>
|
||||||
|
<string name="inbox_empty">Walang hindi pa nababasang chat.</string>
|
||||||
|
<string name="inbox_new_count">%1$d bago</string>
|
||||||
|
<string name="back">Bumalik</string>
|
||||||
|
<string name="block">I-block</string>
|
||||||
|
<string name="unblock">I-unblock</string>
|
||||||
|
<string name="button_image">Larawan</string>
|
||||||
|
<string name="button_send">Ipadala</string>
|
||||||
|
</resources>
|
||||||
41
android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
41
android/app/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="landing_title">直接进入聊天</string>
|
||||||
|
<string name="landing_copy">简洁、快速、无需复杂步骤。创建个人资料并立即开始聊天。</string>
|
||||||
|
<string name="feature_worldwide_chat">全球聊天</string>
|
||||||
|
<string name="feature_image_exchange">图片交换</string>
|
||||||
|
<string name="feature_compact_controls">简洁操作</string>
|
||||||
|
<string name="profile_title">开始个人资料</string>
|
||||||
|
<string name="profile_copy">只需少量信息即可开始。</string>
|
||||||
|
<string name="label_nick">请输入聊天昵称</string>
|
||||||
|
<string name="label_gender">性别</string>
|
||||||
|
<string name="label_age">年龄</string>
|
||||||
|
<string name="label_country">国家</string>
|
||||||
|
<string name="button_start_chat">开始聊天</string>
|
||||||
|
<string name="gender_female">女性</string>
|
||||||
|
<string name="gender_male">男性</string>
|
||||||
|
<string name="gender_pair">情侣</string>
|
||||||
|
<string name="gender_trans_mf">跨性别 (男->女)</string>
|
||||||
|
<string name="gender_trans_fm">跨性别 (女->男)</string>
|
||||||
|
<string name="tab_search">搜索</string>
|
||||||
|
<string name="tab_inbox">收件箱</string>
|
||||||
|
<string name="tab_history">历史</string>
|
||||||
|
<string name="logout">离开</string>
|
||||||
|
<string name="status_online">在线</string>
|
||||||
|
<string name="status_connecting">正在连接...</string>
|
||||||
|
<string name="search_username_includes">用户名包含</string>
|
||||||
|
<string name="search_from_age">从年龄</string>
|
||||||
|
<string name="search_to_age">到年龄</string>
|
||||||
|
<string name="search_country">国家</string>
|
||||||
|
<string name="search_genders">性别</string>
|
||||||
|
<string name="search_all">全部</string>
|
||||||
|
<string name="search_button">搜索</string>
|
||||||
|
<string name="search_no_results">没有结果。</string>
|
||||||
|
<string name="search_min_age_error">最小年龄不能大于最大年龄。</string>
|
||||||
|
<string name="inbox_empty">没有未读聊天。</string>
|
||||||
|
<string name="inbox_new_count">%1$d 条新消息</string>
|
||||||
|
<string name="back">返回</string>
|
||||||
|
<string name="block">屏蔽</string>
|
||||||
|
<string name="unblock">取消屏蔽</string>
|
||||||
|
<string name="button_image">图片</string>
|
||||||
|
<string name="button_send">发送</string>
|
||||||
|
</resources>
|
||||||
103
android/app/src/main/res/values/strings.xml
Normal file
103
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">YPChat</string>
|
||||||
|
<string name="landing_eyebrow">SingleChat</string>
|
||||||
|
<string name="landing_title">Directly into chat</string>
|
||||||
|
<string name="landing_copy">Compact, fast and without detours. Create your profile and start a conversation right away.</string>
|
||||||
|
<string name="feature_worldwide_chat">Worldwide chat</string>
|
||||||
|
<string name="feature_image_exchange">Image exchange</string>
|
||||||
|
<string name="feature_compact_controls">Compact controls</string>
|
||||||
|
<string name="profile_title">Start profile</string>
|
||||||
|
<string name="profile_copy">A few details are enough to get started.</string>
|
||||||
|
<string name="label_nick">Please enter your chat nickname</string>
|
||||||
|
<string name="label_gender">Gender</string>
|
||||||
|
<string name="label_age">Age</string>
|
||||||
|
<string name="label_country">Country</string>
|
||||||
|
<string name="button_start_chat">Start chat</string>
|
||||||
|
<string name="gender_female">Female</string>
|
||||||
|
<string name="gender_male">Male</string>
|
||||||
|
<string name="gender_pair">Couple</string>
|
||||||
|
<string name="gender_trans_mf">Transgender (M->F)</string>
|
||||||
|
<string name="gender_trans_fm">Transgender (F->M)</string>
|
||||||
|
<string name="socket_connected">Socket connected</string>
|
||||||
|
<string name="socket_connecting">Connecting socket...</string>
|
||||||
|
<string name="status_online">online</string>
|
||||||
|
<string name="status_connecting">connecting...</string>
|
||||||
|
<string name="tab_online">Online</string>
|
||||||
|
<string name="tab_search">Search</string>
|
||||||
|
<string name="tab_inbox">Inbox</string>
|
||||||
|
<string name="tab_history">History</string>
|
||||||
|
<string name="tab_console">Console</string>
|
||||||
|
<string name="tab_more">More</string>
|
||||||
|
<string name="logout">Logout</string>
|
||||||
|
<string name="timeout_in">Timeout in %1$s</string>
|
||||||
|
<string name="no_users_online">No other users online yet.</string>
|
||||||
|
<string name="search_username_includes">Username contains</string>
|
||||||
|
<string name="search_from_age">From age</string>
|
||||||
|
<string name="search_to_age">To age</string>
|
||||||
|
<string name="search_country">Country</string>
|
||||||
|
<string name="search_genders">Genders</string>
|
||||||
|
<string name="search_all">All</string>
|
||||||
|
<string name="search_button">Search</string>
|
||||||
|
<string name="search_no_results">No results.</string>
|
||||||
|
<string name="search_min_age_error">Minimum age must not be greater than maximum age.</string>
|
||||||
|
<string name="inbox_empty">No unread chats.</string>
|
||||||
|
<string name="inbox_new_count">%1$d new</string>
|
||||||
|
<string name="history_empty">No history yet.</string>
|
||||||
|
<string name="no_message">No message</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="block">Block</string>
|
||||||
|
<string name="unblock">Unblock</string>
|
||||||
|
<string name="message_placeholder">Message</string>
|
||||||
|
<string name="button_image">Image</string>
|
||||||
|
<string name="button_send">Send</string>
|
||||||
|
<string name="button_smileys">Smileys</string>
|
||||||
|
<string name="image_message">Image message</string>
|
||||||
|
<string name="image_upload_in_progress">Uploading image...</string>
|
||||||
|
<string name="image_upload_success">Image uploaded.</string>
|
||||||
|
<string name="image_upload_failed">Image upload failed.</string>
|
||||||
|
<string name="image_upload_too_large">Image is larger than 5 MB.</string>
|
||||||
|
<string name="image_upload_open_failed">Image could not be opened.</string>
|
||||||
|
<string name="feedback_created_at">Received %1$s</string>
|
||||||
|
<string name="feedback_meta_separator"> • </string>
|
||||||
|
<string name="countries_load_error">Country list could not be loaded: %1$s</string>
|
||||||
|
<string name="user_blocked">%1$s has been blocked</string>
|
||||||
|
<string name="user_unblocked">%1$s has been unblocked</string>
|
||||||
|
<string name="feedback_title">Feedback</string>
|
||||||
|
<string name="feedback_comment">Comment</string>
|
||||||
|
<string name="feedback_send">Send feedback</string>
|
||||||
|
<string name="feedback_saved">Feedback saved.</string>
|
||||||
|
<string name="feedback_empty">No feedback yet.</string>
|
||||||
|
<string name="anonymous">Anonymous</string>
|
||||||
|
<string name="feedback_admin_user">Admin user</string>
|
||||||
|
<string name="feedback_admin_password">Password</string>
|
||||||
|
<string name="feedback_admin_login">Admin login</string>
|
||||||
|
<string name="feedback_admin_logout">Logout admin</string>
|
||||||
|
<string name="feedback_delete">Delete</string>
|
||||||
|
<string name="console_title">Console</string>
|
||||||
|
<string name="console_placeholder">Enter /command or admin login input</string>
|
||||||
|
<string name="console_send">Send</string>
|
||||||
|
<string name="console_empty">No console output yet.</string>
|
||||||
|
<string name="more_title">More</string>
|
||||||
|
<string name="more_feedback">Feedback</string>
|
||||||
|
<string name="more_partners">Partners</string>
|
||||||
|
<string name="more_faq">FAQ</string>
|
||||||
|
<string name="more_rules">Rules</string>
|
||||||
|
<string name="more_safety">Safety</string>
|
||||||
|
<string name="more_imprint">Imprint</string>
|
||||||
|
<string name="more_back">Back to overview</string>
|
||||||
|
<string name="partners_intro">Recommended and friendly projects for our community.</string>
|
||||||
|
<string name="faq_intro">Answers to common questions about the chat.</string>
|
||||||
|
<string name="rules_intro">Basic rules for respectful chatting.</string>
|
||||||
|
<string name="safety_intro">Tips for privacy and safer usage.</string>
|
||||||
|
<string name="imprint_intro">Legal notice and contact details.</string>
|
||||||
|
<string name="external_link">External link</string>
|
||||||
|
<string name="faq_title">Frequently Asked Questions</string>
|
||||||
|
<string name="rules_title">Chat Rules</string>
|
||||||
|
<string name="safety_title">Safety and Privacy</string>
|
||||||
|
<string name="imprint_title">Imprint</string>
|
||||||
|
<string name="partners_title">Partners</string>
|
||||||
|
<string name="faq_body">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.</string>
|
||||||
|
<string name="rules_body">No insults, hate speech, illegal content, spam or unwanted harassment. Only send images you are allowed to share and respect the privacy of others.</string>
|
||||||
|
<string name="safety_body">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.</string>
|
||||||
|
<string name="imprint_body">Torsten Schulz, Friedrich-Stampfer-Str. 21, 60437 Frankfurt. Contact: tsschulz@tsschulz.de. External links are the responsibility of their operators.</string>
|
||||||
|
</resources>
|
||||||
7
android/app/src/main/res/values/styles.xml
Normal file
7
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.YpChat" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
<item name="android:windowActionBar">false</item>
|
||||||
|
<item name="android:fontFamily">sans</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
4
android/build.gradle.kts
Normal file
4
android/build.gradle.kts
Normal file
@@ -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
|
||||||
|
}
|
||||||
11
android/gradle.properties
Normal file
11
android/gradle.properties
Normal file
@@ -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
|
||||||
13
android/gradle/gradle-daemon-jvm.properties
Normal file
13
android/gradle/gradle-daemon-jvm.properties
Normal file
@@ -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
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||||
251
android/gradlew
vendored
Normal file
251
android/gradlew
vendored
Normal file
@@ -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" "$@"
|
||||||
94
android/gradlew.bat
vendored
Normal file
94
android/gradlew.bat
vendored
Normal file
@@ -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
|
||||||
4
android/key.properties.example
Normal file
4
android/key.properties.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
storeFile=release-upload-key.jks
|
||||||
|
storePassword=CHANGE_ME
|
||||||
|
keyAlias=upload
|
||||||
|
keyPassword=CHANGE_ME
|
||||||
21
android/settings.gradle.kts
Normal file
21
android/settings.gradle.kts
Normal file
@@ -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")
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<a href="/faq">FAQ</a>
|
<a href="/faq">FAQ</a>
|
||||||
<a href="/regeln">Regeln</a>
|
<a href="/regeln">Regeln</a>
|
||||||
<a href="/sicherheit">Sicherheit</a>
|
<a href="/sicherheit">Sicherheit</a>
|
||||||
|
<a href="/datenschutz">Datenschutz</a>
|
||||||
<a href="#" @click.prevent="showFeedback = true">Feedback</a>
|
<a href="#" @click.prevent="showFeedback = true">Feedback</a>
|
||||||
<a href="#" @click.prevent="showImprint = true">Impressum</a>
|
<a href="#" @click.prevent="showImprint = true">Impressum</a>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PartnersView from '../views/PartnersView.vue';
|
|||||||
import MockupView from '../views/MockupView.vue';
|
import MockupView from '../views/MockupView.vue';
|
||||||
import FeedbackView from '../views/FeedbackView.vue';
|
import FeedbackView from '../views/FeedbackView.vue';
|
||||||
import FaqView from '../views/FaqView.vue';
|
import FaqView from '../views/FaqView.vue';
|
||||||
|
import PrivacyView from '../views/PrivacyView.vue';
|
||||||
import RulesView from '../views/RulesView.vue';
|
import RulesView from '../views/RulesView.vue';
|
||||||
import SafetyView from '../views/SafetyView.vue';
|
import SafetyView from '../views/SafetyView.vue';
|
||||||
|
|
||||||
@@ -137,6 +138,20 @@ const safetySchema = {
|
|||||||
inLanguage: 'de-DE'
|
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 = [
|
const routes = [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -234,6 +249,22 @@ const routes = [
|
|||||||
schema: safetySchema
|
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',
|
path: '/mockup-redesign',
|
||||||
name: 'mockup-redesign',
|
name: 'mockup-redesign',
|
||||||
|
|||||||
141
client/src/views/PrivacyView.vue
Normal file
141
client/src/views/PrivacyView.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-container">
|
||||||
|
<header class="header">
|
||||||
|
<router-link to="/" class="app-brand app-brand-link">
|
||||||
|
<span class="app-brand-mark">S</span>
|
||||||
|
<div class="app-brand-copy">
|
||||||
|
<span class="app-brand-eyebrow">SingleChat</span>
|
||||||
|
<h1>Datenschutz</h1>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
<HeaderAdBanner />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content-page">
|
||||||
|
<h2>Datenschutzerklärung für Website und App</h2>
|
||||||
|
<p>
|
||||||
|
Diese Datenschutzerklärung gilt für die Website und die Android-App von SingleChat beziehungsweise YPChat unter
|
||||||
|
der Domain <strong>www.ypchat.net</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>1. Verantwortlicher</h3>
|
||||||
|
<p>
|
||||||
|
Torsten Schulz<br>
|
||||||
|
Friedrich-Stampfer-Str. 21<br>
|
||||||
|
60437 Frankfurt<br>
|
||||||
|
E-Mail: <a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>2. Welche Daten verarbeitet werden</h3>
|
||||||
|
<p>Im Rahmen der Nutzung können insbesondere folgende Daten verarbeitet werden:</p>
|
||||||
|
<ul>
|
||||||
|
<li>frei gewählter Nickname</li>
|
||||||
|
<li>Alter, Geschlecht und Land</li>
|
||||||
|
<li>Chat-Nachrichten</li>
|
||||||
|
<li>hochgeladene Bilder</li>
|
||||||
|
<li>Feedback-Nachrichten</li>
|
||||||
|
<li>technische Sitzungs- und Verbindungsdaten</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3. Zweck der Verarbeitung</h3>
|
||||||
|
<p>Die Verarbeitung erfolgt, um die Chat-Funktionen bereitzustellen und sicher zu betreiben, insbesondere für:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Login und Wiedererkennung einer Sitzung</li>
|
||||||
|
<li>Darstellung von Profilangaben im Chat</li>
|
||||||
|
<li>Versand und Empfang von Nachrichten</li>
|
||||||
|
<li>Versand und Abruf von Bildern</li>
|
||||||
|
<li>Suche, Verlauf, Posteingang und Blockierfunktionen</li>
|
||||||
|
<li>Bearbeitung von Feedback und Missbrauchshinweisen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>4. Chat-Nachrichten und Profilangaben</h3>
|
||||||
|
<p>
|
||||||
|
Wenn du den Dienst nutzt, werden von dir eingegebene Profilangaben wie Nickname, Alter, Geschlecht und Land für
|
||||||
|
die Chat-Funktion verwendet. Chat-Nachrichten werden technisch verarbeitet, damit Unterhaltungen in Echtzeit
|
||||||
|
zugestellt werden können.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>5. Bilder</h3>
|
||||||
|
<p>
|
||||||
|
Bilder werden nur verarbeitet, wenn du sie aktiv auswählst und hochlädst. Nach aktuellem Systemstand werden
|
||||||
|
hochgeladene Bilder serverseitig temporär gespeichert und nach Ablauf einer begrenzten Zeit wieder entfernt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>6. Sitzungen, Cookies und technische Protokolle</h3>
|
||||||
|
<p>
|
||||||
|
Für den Betrieb des Dienstes werden Sitzungsdaten verwendet. Dazu gehören insbesondere technisch notwendige
|
||||||
|
Session-Informationen, damit ein Login erhalten bleibt und Socket- sowie API-Anfragen korrekt zugeordnet werden
|
||||||
|
können. Zusätzlich können im Rahmen des Serverbetriebs technische Protokolldaten anfallen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>7. Feedback und Missbrauchsmeldungen</h3>
|
||||||
|
<p>
|
||||||
|
Wenn du Feedback sendest, werden die von dir eingetragenen Inhalte verarbeitet, um Hinweise, Fehlermeldungen oder
|
||||||
|
Missbrauchsmeldungen zu bearbeiten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>8. Weitergabe an Dritte</h3>
|
||||||
|
<p>
|
||||||
|
Eine Weitergabe personenbezogener Daten an Dritte erfolgt nicht zu Werbezwecken. Soweit externe technische
|
||||||
|
Dienstleister oder Hosting-Anbieter eingebunden sind, kann eine Verarbeitung im Rahmen des technischen Betriebs
|
||||||
|
erforderlich sein.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>9. Verschlüsselung</h3>
|
||||||
|
<p>
|
||||||
|
Die produktive Bereitstellung der Website und der App erfolgt über verschlüsselte Verbindungen, damit Daten bei der
|
||||||
|
Übertragung geschützt sind.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>10. Deine Rechte</h3>
|
||||||
|
<p>
|
||||||
|
Du hast im Rahmen der gesetzlichen Vorschriften insbesondere das Recht auf Auskunft, Berichtigung, Löschung,
|
||||||
|
Einschränkung der Verarbeitung sowie Beschwerde bei einer zuständigen Aufsichtsbehörde.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>11. Kontakt zum Datenschutz</h3>
|
||||||
|
<p>
|
||||||
|
Bei Fragen zum Datenschutz oder wenn du eine datenschutzbezogene Anfrage stellen möchtest, kontaktiere bitte:
|
||||||
|
<a href="mailto:tsschulz@tsschulz.de">tsschulz@tsschulz.de</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>12. Stand</h3>
|
||||||
|
<p>Stand dieser Datenschutzerklärung: 22. April 2026</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<ImprintContainer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import HeaderAdBanner from '../components/HeaderAdBanner.vue';
|
||||||
|
import ImprintContainer from '../components/ImprintContainer.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.content-page {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px 14px 36px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #344038;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-page h2 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-page h3 {
|
||||||
|
margin: 18px 0 6px;
|
||||||
|
color: #18201b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-page a {
|
||||||
|
color: #245c3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -35,8 +35,8 @@
|
|||||||
|
|
||||||
<h3>Datenschutz</h3>
|
<h3>Datenschutz</h3>
|
||||||
<p>
|
<p>
|
||||||
Details findest du im Impressum/Datenschutz-Hinweis unten auf der Seite. Wenn du Fragen hast, kontaktiere uns
|
Details findest du auf der Seite <router-link to="/datenschutz">Datenschutz</router-link>. Wenn du Fragen hast,
|
||||||
gern über Feedback. Für den respektvollen Umgang im Chat beachte zusätzlich unsere
|
kontaktiere uns gern über Feedback. Für den respektvollen Umgang im Chat beachte zusätzlich unsere
|
||||||
<router-link to="/regeln">Chat-Regeln</router-link>.
|
<router-link to="/regeln">Chat-Regeln</router-link>.
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
@@ -77,4 +77,3 @@ import ImprintContainer from '../components/ImprintContainer.vue';
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
"dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
|
||||||
"install:all": "npm install && cd client && npm install",
|
"install:all": "npm install && cd client && npm install",
|
||||||
"build": "cd client && npm run build",
|
"build": "cd client && npm run build",
|
||||||
|
"i18n:android": "node scripts/generate-android-i18n.mjs",
|
||||||
"start": "NODE_ENV=production node server/index.js",
|
"start": "NODE_ENV=production node server/index.js",
|
||||||
"start:prod": "NODE_ENV=production PORT=4000 node server/index.js"
|
"start:prod": "NODE_ENV=production PORT=4000 node server/index.js"
|
||||||
},
|
},
|
||||||
@@ -26,4 +27,3 @@
|
|||||||
"concurrently": "^8.2.2"
|
"concurrently": "^8.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
79
scripts/generate-android-i18n.mjs
Normal file
79
scripts/generate-android-i18n.mjs
Normal file
@@ -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, '\\"')
|
||||||
|
.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 = ["<resources>"];
|
||||||
|
|
||||||
|
for (const [webKey, androidKey] of Object.entries(keyMap)) {
|
||||||
|
const value = json[webKey];
|
||||||
|
if (!value || typeof value !== "string") continue;
|
||||||
|
lines.push(` <string name="${androidKey}">${xmlEscape(value.replace(/<[^>]+>/g, "").trim())}</string>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("</resources>", "");
|
||||||
|
await mkdir(targetDir, { recursive: true });
|
||||||
|
await writeFile(targetPath, lines.join("\n"), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Android i18n files generated from web locales.");
|
||||||
@@ -174,7 +174,7 @@ setupBroadcast(io, __dirname);
|
|||||||
// SPA-Fallback muss nach allen anderen Routen stehen
|
// SPA-Fallback muss nach allen anderen Routen stehen
|
||||||
if (IS_PRODUCTION) {
|
if (IS_PRODUCTION) {
|
||||||
const distPath = join(__dirname, '../docroot/dist');
|
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));
|
app.use(express.static(distPath));
|
||||||
// Fallback für Vue Router (SPA) - muss am Ende stehen
|
// Fallback für Vue Router (SPA) - muss am Ende stehen
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
@@ -202,7 +202,10 @@ if (IS_PRODUCTION) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Server starten
|
// 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, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`Server läuft auf http://${HOST}:${PORT}`);
|
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');
|
console.log('Production-Modus: HTTPS über Apache Reverse Proxy');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,30 @@ const seoData = {
|
|||||||
},
|
},
|
||||||
inLanguage: 'de-DE'
|
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: /faq
|
||||||
Allow: /regeln
|
Allow: /regeln
|
||||||
Allow: /sicherheit
|
Allow: /sicherheit
|
||||||
|
Allow: /datenschutz
|
||||||
Disallow: /api/
|
Disallow: /api/
|
||||||
Disallow: /static/logs/
|
Disallow: /static/logs/
|
||||||
Disallow: /mockup-redesign
|
Disallow: /mockup-redesign
|
||||||
|
|||||||
Reference in New Issue
Block a user