Upload via GUI (56 Dateien)

This commit is contained in:
2026-10-21 19:13:01 +00:00
parent fe82741cc5
commit 927c03bc71
18 changed files with 7177 additions and 6240 deletions
+32 -1
View File
@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Popups bei neuen Tickets (ab Android 13 fragt die App einmal nach) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".DeskHubApplication"
android:allowBackup="false"
android:fullBackupContent="false"
android:icon="@mipmap/ic_launcher"
@@ -14,13 +17,41 @@
<meta-data
android:name="de.deskhub.staff.creator"
android:value="M_Viper" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_notification" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="tickets" />
<service
android:name=".push.DeskHubMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".push.PushReplyReceiver"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".DeskHubWidget"
android:exported="true"
android:label="DeskHub Tickets">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/deskhub_widget_info" />
</receiver>
</application>
</manifest>
@@ -0,0 +1,60 @@
package de.deskhub.staff
import android.app.Application
import android.content.Context
import android.os.Build
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.remote.dto.CrashReportRequest
import de.deskhub.staff.data.security.SecureSessionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Merkt sich einen Absturz (nur Fehlerart und die ersten Stack-Zeilen, keine Ticketinhalte) und meldet ihn beim nächsten Start nach
* der Anmeldung an den eigenen DeskHub-Server. Dort steht er im Aktivitätsprotokoll des Adminportals. Nichts geht an Dritte.
*/
object CrashReporter {
private const val PREFS = "deskhub_crash"
private const val KEY = "last_trace"
fun install(app: Application) {
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
runCatching {
val trace = buildString {
append(error.javaClass.name).append(": ").append(error.message.orEmpty().take(120))
error.stackTrace.take(6).forEach { append(" | ").append(it.toString()) }
}
app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, trace).commit()
}
previous?.uncaughtException(thread, error)
}
}
/** Sendet einen gemerkten Absturz (falls vorhanden) und löscht ihn danach. Fehler werden still ignoriert. */
suspend fun sendPending(context: Context) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val trace = prefs.getString(KEY, null)?.takeIf { it.isNotBlank() } ?: return
try {
val session = SecureSessionStore(context.applicationContext).load() ?: return
if (session.token.isBlank() || session.serverUrl.isBlank()) return
withContext(Dispatchers.IO) {
DeskHubApi(session.serverUrl) { session.token }.crashReport(
CrashReportRequest(BuildConfig.VERSION_NAME, "${Build.MANUFACTURER} ${Build.MODEL}".trim(), trace),
)
}
prefs.edit().remove(KEY).apply()
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
// beim nächsten Start erneut versuchen
}
}
}
class DeskHubApplication : Application() {
override fun onCreate() {
super.onCreate()
CrashReporter.install(this)
}
}
@@ -0,0 +1,101 @@
package de.deskhub.staff
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.View
import android.widget.RemoteViews
import de.deskhub.staff.data.remote.ApiException
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.security.SecureSessionStore
import kotlinx.coroutines.runBlocking
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Startbildschirm-Widget: Anzahl der mir zugewiesenen offenen, der nicht zugewiesenen und der überfälligen Tickets.
* Es zeigt bewusst nur Zahlen (keine Betreffzeilen oder Kundennamen), damit nichts Vertrauliches auf dem Startbildschirm steht.
* Aktualisiert sich alle 30 Minuten, beim Tippen auf ⟳ und beim Öffnen der App.
*/
class DeskHubWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, manager: AppWidgetManager, appWidgetIds: IntArray) {
refresh(context, manager, appWidgetIds)
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_REFRESH) {
val manager = AppWidgetManager.getInstance(context)
refresh(context, manager, manager.getAppWidgetIds(ComponentName(context, DeskHubWidget::class.java)))
} else {
super.onReceive(context, intent)
}
}
private fun refresh(context: Context, manager: AppWidgetManager, ids: IntArray) {
if (ids.isEmpty()) return
val pending = goAsync()
ids.forEach { manager.updateAppWidget(it, views(context, message = "Aktualisiere …")) }
Thread {
val result = try {
load(context)
} catch (_: Exception) {
WidgetState(message = "Keine Verbindung zum Server.")
}
ids.forEach { manager.updateAppWidget(it, views(context, result.counts, result.message)) }
pending.finish()
}.start()
}
private data class WidgetState(val counts: Triple<Int, Int, Int>? = null, val message: String? = null)
private fun load(context: Context): WidgetState {
val session = SecureSessionStore(context.applicationContext).load()
if (session == null || session.token.isBlank() || session.serverUrl.isBlank()) {
return WidgetState(message = "Bitte in der App anmelden.")
}
return try {
val api = DeskHubApi(session.serverUrl) { session.token }
val d = runBlocking { api.dashboard() }
WidgetState(counts = Triple(d.myOpen, d.unassigned, d.overdue))
} catch (e: ApiException) {
if (e.status == 401 || e.status == 403) WidgetState(message = "Bitte in der App neu anmelden.") else WidgetState(message = "Server-Fehler (${e.status}).")
}
}
private fun views(context: Context, counts: Triple<Int, Int, Int>? = null, message: String? = null): RemoteViews {
val v = RemoteViews(context.packageName, R.layout.widget_deskhub)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val open = PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java), flags)
v.setOnClickPendingIntent(R.id.widget_root, open)
val refresh = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH)
v.setOnClickPendingIntent(R.id.widget_refresh, PendingIntent.getBroadcast(context, 1, refresh, flags))
if (counts != null) {
v.setTextViewText(R.id.widget_my_open, counts.first.toString())
v.setTextViewText(R.id.widget_unassigned, counts.second.toString())
v.setTextViewText(R.id.widget_overdue, counts.third.toString())
v.setViewVisibility(R.id.widget_message, View.GONE)
v.setViewVisibility(R.id.widget_tiles, View.VISIBLE)
v.setTextViewText(R.id.widget_time, "Stand " + SimpleDateFormat("HH:mm", Locale.GERMANY).format(Date()))
} else {
v.setTextViewText(R.id.widget_message, message ?: "")
v.setViewVisibility(R.id.widget_message, View.VISIBLE)
v.setViewVisibility(R.id.widget_tiles, View.INVISIBLE)
v.setTextViewText(R.id.widget_time, "")
}
return v
}
companion object {
const val ACTION_REFRESH = "de.deskhub.staff.WIDGET_REFRESH"
/** Stößt eine Aktualisierung aller Widgets an (zum Beispiel beim Öffnen der App). */
fun requestUpdate(context: Context) {
val intent = Intent(context, DeskHubWidget::class.java).setAction(ACTION_REFRESH)
context.sendBroadcast(intent)
}
}
}
@@ -1,23 +1,82 @@
package de.deskhub.staff
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.lifecycle.viewmodel.compose.viewModel
import de.deskhub.staff.data.TicketRepository
import de.deskhub.staff.data.security.SecureSessionStore
import de.deskhub.staff.ui.DeskHubApp
import de.deskhub.staff.ui.DeskHubViewModel
import de.deskhub.staff.ui.DeskHubViewModelFactory
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val repository = TicketRepository(SecureSessionStore(applicationContext))
val factory = DeskHubViewModelFactory(repository)
setContent {
val viewModel: DeskHubViewModel = viewModel(factory = factory)
DeskHubApp(viewModel)
}
}
}
package de.deskhub.staff
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat
import de.deskhub.staff.data.TicketRepository
import de.deskhub.staff.data.security.SecureSessionStore
import de.deskhub.staff.push.DeskHubPush
import de.deskhub.staff.push.PushRegistration
import de.deskhub.staff.ui.DeskHubApp
import de.deskhub.staff.ui.DeskHubViewModel
import de.deskhub.staff.ui.DeskHubViewModelFactory
class MainActivity : ComponentActivity() {
private val viewModel: DeskHubViewModel by viewModels {
DeskHubViewModelFactory(TicketRepository(SecureSessionStore(applicationContext)))
}
private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
/** Jede Berührung meldet (höchstens einmal pro Minute) dem Server, dass die App benutzt wird. */
override fun onUserInteraction() {
super.onUserInteraction()
PresencePing.onUserInteraction(applicationContext)
}
override fun onResume() {
super.onResume()
DeskHubWidget.requestUpdate(applicationContext)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleNotificationIntent(intent)
setContent {
val state by viewModel.state.collectAsState()
val signedIn = state.staff != null
// Nach der Anmeldung: um Erlaubnis für Benachrichtigungen bitten (einmal) und das Gerät für Push anmelden
LaunchedEffect(signedIn) {
if (signedIn) {
requestNotificationPermissionOnce()
PushRegistration.registerCurrent(applicationContext)
CrashReporter.sendPending(applicationContext)
}
}
DeskHubApp(viewModel)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleNotificationIntent(intent)
}
/** Tipp auf eine Benachrichtigung: das Ticket aus dem Intent öffnen. */
private fun handleNotificationIntent(intent: Intent?) {
val number = intent?.getStringExtra(DeskHubPush.EXTRA_TICKET)?.trim().orEmpty()
if (number.isNotEmpty()) {
intent?.removeExtra(DeskHubPush.EXTRA_TICKET)
viewModel.openTicketFromNotification(number)
}
}
private fun requestNotificationPermissionOnce() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) return
val prefs = getSharedPreferences("deskhub_push", MODE_PRIVATE)
if (prefs.getBoolean("asked_notifications", false)) return
prefs.edit().putBoolean("asked_notifications", true).apply()
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -0,0 +1,33 @@
package de.deskhub.staff
import android.content.Context
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.security.SecureSessionStore
import kotlinx.coroutines.runBlocking
import kotlin.concurrent.thread
/**
* Meldet dem Server höchstens einmal pro Minute, dass die App gerade bedient wird. Der Server setzt Mitarbeiter nach längerer
* Inaktivität (Standard 30 Minuten) automatisch auf "Offline" und holt sie bei dieser Meldung wieder zurück. Fehler werden still
* ignoriert. Wird nur bei echter Bedienung aufgerufen (Berührung), nicht bei Hintergrund-Abfragen.
*/
object PresencePing {
private const val MIN_INTERVAL_MS = 60_000L
@Volatile private var lastPing = 0L
fun onUserInteraction(context: Context) {
val now = System.currentTimeMillis()
if (now - lastPing < MIN_INTERVAL_MS) return
lastPing = now
val app = context.applicationContext
thread(name = "presence-ping") {
try {
val session = SecureSessionStore(app).load() ?: return@thread
if (session.token.isBlank() || session.serverUrl.isBlank()) return@thread
runBlocking { DeskHubApi(session.serverUrl) { session.token }.presence() }
} catch (e: Exception) {
lastPing = 0L // beim nächsten Tipp erneut versuchen
}
}
}
}
@@ -240,8 +240,8 @@ class TicketRepository(
)
}
suspend fun addMessage(number: String, type: String, message: String, setStatus: String?) {
requireApi().addMessage(MessageRequest(number, type, message, setStatus))
suspend fun addMessage(number: String, type: String, message: String, setStatus: String?, signature: String? = null) {
requireApi().addMessage(MessageRequest(number, type, message, setStatus, signature))
}
suspend fun cannedResponses(): List<CannedResponse> =
@@ -301,7 +301,12 @@ class TicketRepository(
}
suspend fun logout() {
requireApi().logout()
val api = requireApi()
// Dieses Gerät für Push abmelden, damit nach dem Ausloggen keine Popups mehr kommen (Fehler dabei verhindern das Ausloggen nicht)
sessionStore.loadPushToken()?.let { token ->
runCatching { api.pushDevice(de.deskhub.staff.data.remote.dto.PushDeviceRequest(action = "unregister", token = token)) }
}
api.logout()
clearSession()
}
@@ -705,7 +710,7 @@ class TicketRepository(
return AvailabilityData(myStatus = r.myStatus, myNote = r.myNote, team = r.team.map(TeamMemberDto::toDomain))
}
suspend fun setAvailability(status: String, note: String) { requireApi().setAvailability(status, note) }
suspend fun setAvailability(status: String, note: String, staffId: Int? = null) { requireApi().setAvailability(status, note, staffId) }
// ─── Ticket-Regeln (Automatisierung) ───────────────────────────────────────
@@ -811,7 +816,7 @@ private fun ChatMessageDto.toDomain() = ChatMessage(id, fromStaffId, fromName, m
private fun LiveChatListItemDto.toDomain() = LiveChatSummary(number, name, preview, needsReply, updatedAt)
private fun LiveChatAttachmentDto.toDomain() = LiveChatAttachment(id, name, size)
private fun LiveChatMessageDto.toDomain() = LiveChatMessage(id, authorType, authorName, message, createdAt, attachments.map(LiveChatAttachmentDto::toDomain))
private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt)
private fun TeamMemberDto.toDomain() = TeamMember(id, name, status, note, isOnVacation, updatedAt, autoOffline)
private fun KeyLabelDto.toDomain() = KeyLabel(value, label)
private fun TicketRuleConditionDto.toDomain() = TicketRuleCondition(field, operator, value)
private fun TicketRuleActionDto.toDomain() = TicketRuleAction(type, value, valueLabel)
@@ -898,4 +903,12 @@ private fun TicketDetailDto.toDomain() = TicketDetail(
)
},
permissions = permissions,
customFields = customFields.map { de.deskhub.staff.domain.model.CustomFieldValue(it.label, it.value) },
replySignatures = replySignatures?.let { sig ->
de.deskhub.staff.domain.model.ReplySignatures(
default = sig.default,
own = de.deskhub.staff.domain.model.ReplySignature(sig.own.available, sig.own.text, sig.own.hasImage, sig.own.name, sig.own.imageUrl),
department = de.deskhub.staff.domain.model.ReplySignature(sig.department.available, sig.department.text, sig.department.hasImage, sig.department.name, sig.department.imageUrl),
)
},
)
@@ -207,6 +207,19 @@ class DeskHubApi(
execute("logout.php", "POST", emptyMap<String, String>(), Map::class.java)
}
suspend fun pushPrefs(): de.deskhub.staff.data.remote.dto.PushPrefsDto =
execute("push_prefs.php", "GET", null, de.deskhub.staff.data.remote.dto.PushPrefsDto::class.java)
suspend fun savePushPrefs(request: de.deskhub.staff.data.remote.dto.PushPrefsRequest): de.deskhub.staff.data.remote.dto.PushPrefsDto =
execute("push_prefs.php", "POST", request, de.deskhub.staff.data.remote.dto.PushPrefsDto::class.java)
suspend fun crashReport(request: de.deskhub.staff.data.remote.dto.CrashReportRequest) {
execute("crash_report.php", "POST", request, Map::class.java)
}
suspend fun pushDevice(request: de.deskhub.staff.data.remote.dto.PushDeviceRequest): de.deskhub.staff.data.remote.dto.PushDeviceResponse =
execute("push_device.php", "POST", request, de.deskhub.staff.data.remote.dto.PushDeviceResponse::class.java)
// ─── Admin: Wissensdatenbank ─────────────────────────────────────────────
suspend fun kb(): KbResponse =
@@ -484,8 +497,13 @@ class DeskHubApi(
suspend fun availability(): AvailabilityResponse =
execute("availability.php", "GET", null, AvailabilityResponse::class.java)
suspend fun setAvailability(status: String, note: String): AvailabilitySetResponse =
execute("availability.php", "POST", AvailabilitySetRequest(status, note), AvailabilitySetResponse::class.java)
suspend fun setAvailability(status: String, note: String, staffId: Int? = null): AvailabilitySetResponse =
execute("availability.php", "POST", AvailabilitySetRequest(status, note, staffId), AvailabilitySetResponse::class.java)
/** Berührungs-Ping: die Person nutzt die App gerade (verhindert den automatischen Offline-Status). */
suspend fun presence() {
execute("presence.php", "POST", emptyMap<String, String>(), Map::class.java)
}
// ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────
@@ -526,6 +544,7 @@ class DeskHubApi(
val token = tokenProvider()
?: throw ApiException(401, "UNAUTHENTICATED", "Keine lokale Anmeldung vorhanden.")
requestBuilder.header("Authorization", "Bearer $token")
requestBuilder.header("X-DeskHub-Authorization", "Bearer $token") // Ersatz-Header: viele Hoster entfernen "Authorization"
}
val body = payload?.let { gson.toJson(it).toRequestBody(jsonType) }
when (method) {
@@ -574,6 +593,7 @@ class DeskHubApi(
.url("$baseUrl/api/staff/v1/upload_image.php".toHttpUrl())
.header("Accept", "application/json")
.header("Authorization", "Bearer $token")
.header("X-DeskHub-Authorization", "Bearer $token")
.post(body)
.build()
@@ -86,6 +86,16 @@ data class TicketDetailDto(
val lockedByOtherName: String? = null,
val threads: List<TicketThreadDto> = emptyList(),
val permissions: Map<String, Boolean>? = null,
// Signaturen zur Auswahl beim Antworten; null = ältere Serverversion
val replySignatures: ReplySignaturesDto? = null,
// Vom Kunden im Formular ausgefüllte Zusatzfelder; leer bei älteren Servern
val customFields: List<CustomFieldValueDto> = emptyList(),
)
data class CustomFieldValueDto(
val label: String = "",
val type: String = "text",
val value: String = "",
)
data class TicketSpamRequest(
@@ -149,10 +159,54 @@ data class MessageRequest(
val type: String,
val message: String,
val setStatus: String? = null,
// Welche Signatur angehängt wird: "own" (eigene), "dept" (Abteilung), "none"; null = Standard des Servers
val signature: String? = null,
)
data class ReplySignatureDto(
val available: Boolean = false,
val text: String = "",
val hasImage: Boolean = false,
val imageUrl: String = "",
val name: String = "",
)
data class ReplySignaturesDto(
val default: String = "none",
val own: ReplySignatureDto = ReplySignatureDto(),
val department: ReplySignatureDto = ReplySignatureDto(),
)
data class MessageResponse(val threadId: Int = 0)
/** Gerät für Push-Benachrichtigungen beim Server an- oder abmelden (action: "register" oder "unregister"). */
data class PushDeviceRequest(
val action: String,
val token: String,
val deviceName: String = "",
val appVersion: String = "",
)
/** Push-Einstellungen: welche Popups, stille Zeiten (Server: push_prefs.php). */
data class PushEventTypeDto(val key: String = "", val label: String = "")
data class PushPrefsDto(
val events: List<String> = emptyList(),
val eventTypes: List<PushEventTypeDto> = emptyList(),
val quietFrom: String = "",
val quietTo: String = "",
)
data class PushPrefsRequest(val events: List<String>, val quietFrom: String, val quietTo: String)
/** Absturzbericht (Server: crash_report.php). */
data class CrashReportRequest(val appVersion: String, val device: String, val trace: String)
data class PushDeviceResponse(
val registered: Boolean = false,
val serverConfigured: Boolean = false,
)
data class CannedResponseDto(
val id: Int = 0,
val title: String = "",
@@ -975,6 +1029,8 @@ data class TeamMemberDto(
val note: String = "",
val isOnVacation: Boolean = false,
val updatedAt: String? = null,
val lastActiveAt: String? = null,
val autoOffline: Boolean = false,
)
data class AvailabilityResponse(
@@ -983,7 +1039,8 @@ data class AvailabilityResponse(
val team: List<TeamMemberDto> = emptyList(),
)
data class AvailabilitySetRequest(val status: String, val note: String)
// staffId nur für Administratoren: Status eines anderen Mitarbeiters ändern (leer = eigener Status)
data class AvailabilitySetRequest(val status: String, val note: String, val staffId: Int? = null)
data class AvailabilitySetResponse(val ok: Boolean = false, val status: String = "")
// ─── Ticket-Regeln (Automatisierung) ─────────────────────────────────────────
@@ -24,6 +24,7 @@ class SessionStorageException(message: String, cause: Throwable) : Exception(mes
class SecureSessionStore(context: Context) {
private val preferences = context.getSharedPreferences("deskhub_secure_session", Context.MODE_PRIVATE)
private val pushPreferences = context.getSharedPreferences("deskhub_push", Context.MODE_PRIVATE)
private val gson = Gson()
fun save(session: StoredSession) {
@@ -62,6 +63,9 @@ class SecureSessionStore(context: Context) {
fun loadLastServerUrl(): String? = preferences.getString(LAST_SERVER_URL_KEY, null)
/** Firebase-Token dieses Geräts (für das Abmelden beim Ausloggen); steht getrennt von der geschützten Sitzung. */
fun loadPushToken(): String? = pushPreferences.getString("fcm_token", null)?.takeIf { it.isNotBlank() }
private fun encrypt(value: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
@@ -81,6 +81,26 @@ data class TicketDetail(
val threads: List<TicketThread>,
// Rechte für dieses Ticket (gelten pro Abteilung); null = ältere Serverversion -> globale Rechte
val permissions: Map<String, Boolean>? = null,
// Signaturen zur Auswahl beim Antworten; null = ältere Serverversion (keine Auswahl)
val replySignatures: ReplySignatures? = null,
// Vom Kunden im Formular ausgefüllte Zusatzfelder (Bezeichnung, Wert)
val customFields: List<CustomFieldValue> = emptyList(),
)
data class CustomFieldValue(val label: String, val value: String)
data class ReplySignature(
val available: Boolean,
val text: String,
val hasImage: Boolean,
val name: String,
val imageUrl: String = "",
)
data class ReplySignatures(
val default: String,
val own: ReplySignature,
val department: ReplySignature,
)
data class SpamActionResult(
@@ -667,6 +687,7 @@ data class TeamMember(
val note: String,
val isOnVacation: Boolean,
val updatedAt: String?,
val autoOffline: Boolean = false,
)
data class AvailabilityData(
@@ -0,0 +1,16 @@
package de.deskhub.staff.push
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
/** Empfängt Nachrichten von Firebase Cloud Messaging (auch wenn die App geschlossen ist). */
class DeskHubMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
DeskHubPush.show(applicationContext, message.data)
}
/** Firebase vergibt ein neues Geräte-Token (zum Beispiel nach Neuinstallation): beim Server melden. */
override fun onNewToken(token: String) {
PushRegistration.onNewToken(applicationContext, token)
}
}
@@ -0,0 +1,104 @@
package de.deskhub.staff.push
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import de.deskhub.staff.MainActivity
import de.deskhub.staff.R
/**
* Benachrichtigungen (Popups) für neue Tickets, Kundenantworten und Zuweisungen.
*
* Der Server schickt über Firebase Cloud Messaging nur eine kurze Daten-Nachricht (Titel, Text, Ticketnummer). Daraus baut die App
* hier die Benachrichtigung selbst. Ein Tipp darauf öffnet das Ticket in der App.
*/
object DeskHubPush {
const val CHANNEL_TICKETS = "tickets"
const val EXTRA_TICKET = "de.deskhub.staff.extra.TICKET"
/** Nachrichten, die der Server schickt (Feld "type"). */
private val KNOWN_TYPES = setOf("ticket_new", "ticket_reply", "ticket_assigned", "chat_new", "ticket_sla", "ticket_mention", "test")
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java) ?: return
if (manager.getNotificationChannel(CHANNEL_TICKETS) != null) return
manager.createNotificationChannel(
NotificationChannel(CHANNEL_TICKETS, "Tickets", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Neue Tickets, Kundenantworten und Zuweisungen"
},
)
}
/** Ob die App Benachrichtigungen anzeigen darf (ab Android 13 muss der Nutzer zustimmen). */
fun canNotify(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
return false
}
return NotificationManagerCompat.from(context).areNotificationsEnabled()
}
/** Zeigt die Benachrichtigung zu einer Nachricht des Servers. Gibt zurück, ob etwas angezeigt wurde. */
fun show(context: Context, data: Map<String, String>): Boolean {
val type = data["type"].orEmpty()
val title = data["title"].orEmpty().trim()
if (type !in KNOWN_TYPES || title.isEmpty()) return false
if (!canNotify(context)) return false
ensureChannel(context)
val ticket = data["ticket"].orEmpty().trim().take(60)
val open = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
if (ticket.isNotEmpty()) putExtra(EXTRA_TICKET, ticket)
}
val id = notificationId(type, ticket)
val pending = PendingIntent.getActivity(
context, id, open,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val builder = NotificationCompat.Builder(context, CHANNEL_TICKETS)
.setSmallIcon(R.drawable.ic_stat_notification)
.setContentTitle(title.take(120))
.setContentText(data["body"].orEmpty().take(240))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setContentIntent(pending)
// Frei wählbarer Name des Servers (Einstellung des Admins) neben dem App-Namen
data["brand"].orEmpty().trim().take(40).takeIf { it.isNotEmpty() }?.let { builder.setSubText(it) }
// Kundenantwort: direkt aus der Benachrichtigung antworten
if (type == "ticket_reply" && ticket.isNotEmpty()) builder.addAction(replyAction(context, id, ticket))
val notification = builder.build()
// Dieselbe Ticketnummer und Art ersetzt die frühere Benachrichtigung, statt den Bildschirm zu füllen
NotificationManagerCompat.from(context).notify(id, notification)
return true
}
private fun replyAction(context: Context, id: Int, ticket: String): NotificationCompat.Action {
val intent = Intent(context, PushReplyReceiver::class.java).apply {
putExtra(EXTRA_TICKET, ticket)
putExtra(PushReplyReceiver.EXTRA_NOTIFICATION_ID, id)
}
// Muss veränderbar sein, damit Android den eingegebenen Text einfügen kann
val flags = PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
val pending = PendingIntent.getBroadcast(context, id, intent, flags)
val input = androidx.core.app.RemoteInput.Builder(PushReplyReceiver.KEY_REPLY).setLabel("Antwort schreiben").build()
return NotificationCompat.Action.Builder(R.drawable.ic_stat_notification, "Antworten", pending)
.addRemoteInput(input)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.build()
}
fun notificationId(type: String, ticket: String): Int = (type + ":" + ticket).hashCode() and 0x7fffffff
}
@@ -0,0 +1,76 @@
package de.deskhub.staff.push
import android.content.Context
import android.os.Build
import com.google.firebase.messaging.FirebaseMessaging
import de.deskhub.staff.BuildConfig
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.remote.dto.PushDeviceRequest
import de.deskhub.staff.data.security.SecureSessionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
/**
* Meldet das Gerät für Push-Benachrichtigungen beim DeskHub-Server an und ab.
* Das Firebase-Token liegt in eigenen Einstellungen (nicht in den geschützten Sitzungsdaten, die bei einem Fehler gelöscht werden).
*/
object PushRegistration {
private const val PREFS = "deskhub_push"
private const val KEY_TOKEN = "fcm_token"
fun savedToken(context: Context): String? =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_TOKEN, null)?.takeIf { it.isNotBlank() }
private fun saveToken(context: Context, token: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_TOKEN, token).apply()
}
/** Holt das aktuelle Firebase-Token (null, wenn Firebase nicht erreichbar oder nicht eingerichtet ist). */
suspend fun fetchToken(): String? = suspendCancellableCoroutine { cont ->
runCatching {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (cont.isActive) cont.resume(if (task.isSuccessful) task.result else null)
}
}.onFailure { if (cont.isActive) cont.resume(null) }
}
/** Nach der Anmeldung: Berechtigungskanal anlegen, Token holen und beim Server anmelden. Fehler werden still ignoriert (kein Push, App läuft normal). */
suspend fun registerCurrent(context: Context) {
// Push darf die App nie zum Absturz bringen (Server ohne Push-Schnittstelle, kein Netz, Firebase fehlt ...)
try {
DeskHubPush.ensureChannel(context)
val token = fetchToken() ?: return
saveToken(context, token)
withContext(Dispatchers.IO) { registerWithServer(context, token) }
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
// still ignorieren
}
}
/** Firebase hat ein neues Token vergeben. */
fun onNewToken(context: Context, token: String) {
saveToken(context, token)
runCatching { registerWithServer(context, token) }
}
private fun registerWithServer(context: Context, token: String): Boolean {
val session = SecureSessionStore(context.applicationContext).load() ?: return false
if (session.token.isBlank() || session.serverUrl.isBlank()) return false
val api = DeskHubApi(session.serverUrl) { session.token }
val response = kotlinx.coroutines.runBlocking {
api.pushDevice(
PushDeviceRequest(
action = "register",
token = token,
deviceName = "${Build.MANUFACTURER} ${Build.MODEL}".trim(),
appVersion = BuildConfig.VERSION_NAME,
),
)
}
return response.registered
}
}
@@ -0,0 +1,68 @@
package de.deskhub.staff.push
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import de.deskhub.staff.R
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.remote.dto.MessageRequest
import de.deskhub.staff.data.security.SecureSessionStore
import kotlinx.coroutines.runBlocking
import kotlin.concurrent.thread
/** Schnellantwort direkt aus der Benachrichtigung: sendet den eingegebenen Text als Antwort auf das Ticket. */
class PushReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val ticket = intent.getStringExtra(DeskHubPush.EXTRA_TICKET).orEmpty()
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, 0)
val text = RemoteInput.getResultsFromIntent(intent)?.getCharSequence(KEY_REPLY)?.toString()?.trim().orEmpty()
if (ticket.isEmpty() || text.isEmpty()) return
val pending = goAsync()
thread(name = "push-reply") {
val ok = try {
send(context.applicationContext, ticket, text)
} catch (e: Exception) {
false
}
try {
confirm(context, notificationId, ticket, ok)
} finally {
pending.finish()
}
}
}
private fun send(context: Context, ticket: String, text: String): Boolean {
val session = SecureSessionStore(context).load() ?: return false
if (session.token.isBlank() || session.serverUrl.isBlank()) return false
val api = DeskHubApi(session.serverUrl) { session.token }
runBlocking { api.addMessage(MessageRequest(number = ticket, type = "reply", message = toHtml(text))) }
return true
}
/** Ersetzt die Benachrichtigung durch eine Bestätigung (sonst bleibt die Eingabe-Anzeige hängen). */
private fun confirm(context: Context, id: Int, ticket: String, ok: Boolean) {
if (!DeskHubPush.canNotify(context)) return
val n = NotificationCompat.Builder(context, DeskHubPush.CHANNEL_TICKETS)
.setSmallIcon(R.drawable.ic_stat_notification)
.setContentTitle(if (ok) "Antwort gesendet" else "Antwort nicht gesendet")
.setContentText("#$ticket" + if (ok) "" else ": bitte in der App erneut versuchen")
.setTimeoutAfter(if (ok) 4000 else 0)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(id, n)
}
companion object {
const val KEY_REPLY = "reply_text"
const val EXTRA_NOTIFICATION_ID = "de.deskhub.staff.extra.NOTIFICATION_ID"
/** Reiner Text wird für die Nachricht als HTML maskiert, Zeilenumbrüche bleiben erhalten. */
fun toHtml(text: String): String =
text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\r\n", "\n").replace("\n", "<br>")
}
}
File diff suppressed because it is too large Load Diff
@@ -261,6 +261,8 @@ class DeskHubViewModel(
private var liveChatListPollingJob: Job? = null
private var liveChatDetailPollingJob: Job? = null
private var lastLiveChatTypingPingAt = 0L
// Ticket, das über eine Benachrichtigung geöffnet werden soll, bevor die Anmeldung fertig ist
private var pendingTicket: String? = null
init {
restoreSession()
@@ -313,6 +315,7 @@ class DeskHubViewModel(
loadChatContacts()
startActivityPolling()
startTicketsPolling()
applyPendingTicket()
}
}
}.onFailure { error ->
@@ -411,6 +414,16 @@ class DeskHubViewModel(
loadTickets(reset = false)
}
/** Tipp auf eine Push-Benachrichtigung: das Ticket öffnen (sobald die Anmeldung steht). */
fun openTicketFromNotification(number: String) {
if (number.isBlank()) return
if (state.value.staff != null) openTicket(number) else pendingTicket = number
}
private fun applyPendingTicket() {
pendingTicket?.let { pendingTicket = null; openTicket(it) }
}
fun openTicket(number: String) {
viewModelScope.launch {
mutableState.update {
@@ -479,11 +492,11 @@ class DeskHubViewModel(
}
}
fun addMessage(type: String, message: String, setStatus: String?, onSuccess: () -> Unit) {
fun addMessage(type: String, message: String, setStatus: String?, signature: String?, onSuccess: () -> Unit) {
val number = state.value.selectedTicket?.summary?.number ?: return
viewModelScope.launch {
mutableState.update { it.copy(mutationLoading = true, mutationError = null) }
runCatching { repository.addMessage(number, type, message, setStatus) }
runCatching { repository.addMessage(number, type, message, setStatus, signature) }
.onSuccess {
mutableState.update { it.copy(mutationLoading = false) }
onSuccess()
@@ -1419,6 +1432,19 @@ class DeskHubViewModel(
}
}
/** Administrator: Status eines anderen Mitarbeiters ändern. */
fun setMemberAvailability(staffId: Int, status: String, note: String) {
viewModelScope.launch {
mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) }
runCatching { repository.setAvailability(status, note, staffId) }
.onSuccess {
mutableState.update { it.copy(availabilitySaving = false) }
loadAvailability()
}
.onFailure { error -> mutableState.update { it.copy(availabilitySaving = false, availabilityError = error.userMessage()) } }
}
}
fun setMyAvailability(status: String, note: String) {
viewModelScope.launch {
mutableState.update { it.copy(availabilitySaving = true, availabilityError = null) }
@@ -1520,6 +1546,7 @@ class DeskHubViewModel(
loadChatContacts()
startActivityPolling()
startTicketsPolling()
applyPendingTicket()
}
.onFailure { error ->
if (error is ApiException && error.status == 401) repository.clearSession()
@@ -0,0 +1,124 @@
package de.deskhub.staff.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.deskhub.staff.data.remote.DeskHubApi
import de.deskhub.staff.data.remote.dto.PushPrefsRequest
import de.deskhub.staff.data.security.SecureSessionStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Wählen, welche Popups die App zeigt, und stille Zeiten festlegen (die Einstellungen liegen auf dem Server). */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun PushPrefsScreen(onBack: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val types = remember { mutableStateListOf<Pair<String, String>>() }
val selected = remember { mutableStateListOf<String>() }
var quietFrom by remember { mutableStateOf("") }
var quietTo by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) }
var message by remember { mutableStateOf<String?>(null) }
fun api(): DeskHubApi? {
val session = SecureSessionStore(context.applicationContext).load() ?: return null
return DeskHubApi(session.serverUrl) { session.token }
}
LaunchedEffect(Unit) {
runCatching {
val prefs = withContext(Dispatchers.IO) { api()?.pushPrefs() }
if (prefs != null) {
types.clear(); types.addAll(prefs.eventTypes.map { it.key to it.label })
selected.clear(); selected.addAll(prefs.events)
quietFrom = prefs.quietFrom; quietTo = prefs.quietTo
} else {
message = "Nicht angemeldet."
}
}.onFailure { message = "Einstellungen konnten nicht geladen werden (hat der Server die neueste Version?)." }
loading = false
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Benachrichtigungen", fontWeight = FontWeight.Bold) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = LocalChrome.current,
titleContentColor = Color.White,
navigationIconContentColor = Color.White,
),
)
},
) { padding ->
Column(
modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Wofür soll die App ein Popup zeigen?", fontWeight = FontWeight.Bold)
if (loading) Text("Lade …")
types.forEach { (key, label) ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = key in selected, onCheckedChange = { on -> if (on) selected.add(key) else selected.remove(key) })
Text(label)
}
}
if (types.isNotEmpty()) {
Text("Stille Zeiten (keine Popups)", fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = quietFrom, onValueChange = { quietFrom = it.take(5) }, label = { Text("von (HH:MM)") }, singleLine = true, modifier = Modifier.weight(1f))
OutlinedTextField(value = quietTo, onValueChange = { quietTo = it.take(5) }, label = { Text("bis (HH:MM)") }, singleLine = true, modifier = Modifier.weight(1f))
}
Text("Beide leer = immer Popups. Es gilt die Zeit des Servers, auch über Mitternacht (z. B. 22:00 bis 06:00).", style = MaterialTheme.typography.bodySmall)
Button(
onClick = {
scope.launch {
message = runCatching {
withContext(Dispatchers.IO) { api()?.savePushPrefs(PushPrefsRequest(selected.toList(), quietFrom.trim(), quietTo.trim())) }
"Gespeichert."
}.getOrElse { "Nicht gespeichert: bitte Uhrzeiten prüfen (z. B. 22:00) oder später erneut versuchen." }
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("Speichern") }
}
message?.let { Text(it) }
}
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Kleines Glockensymbol für die Statusleiste (einfarbig, Android färbt es ein) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>