Upload via GUI (8346 Dateien)

This commit is contained in:
2026-10-21 16:54:40 +00:00
parent bb8ffee10d
commit 7fb620e7ca
5 changed files with 348 additions and 201 deletions
+36 -47
View File
@@ -1,47 +1,36 @@
param()
$ErrorActionPreference = 'Stop'
$required = @(
'DESKHUB_RELEASE_STORE_FILE',
'DESKHUB_RELEASE_STORE_PASSWORD',
'DESKHUB_RELEASE_KEY_ALIAS',
'DESKHUB_RELEASE_KEY_PASSWORD'
)
$missing = @($required | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) })
if ($missing.Count -gt 0) {
throw "Fehlende Umgebungsvariablen: $($missing -join ', ')"
}
$storeFile = [Environment]::GetEnvironmentVariable('DESKHUB_RELEASE_STORE_FILE')
if (-not (Test-Path -LiteralPath $storeFile -PathType Leaf)) {
throw "Keystore nicht gefunden: $storeFile"
}
$projectRoot = $PSScriptRoot
$javaHome = [Environment]::GetEnvironmentVariable('JAVA_HOME')
if ([string]::IsNullOrWhiteSpace($javaHome)) {
throw 'JAVA_HOME muss auf ein JDK 17 zeigen.'
}
Push-Location $projectRoot
try {
& "$projectRoot\gradlew.bat" clean assembleRelease --no-daemon --console=plain
if ($LASTEXITCODE -ne 0) {
throw "Release-Build fehlgeschlagen (Exitcode $LASTEXITCODE)."
}
$source = "$projectRoot\build\outputs\apk\release\DeskHubMobile-release.apk"
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
throw 'Signierte Release-APK wurde nicht erzeugt.'
}
$artifactDirectory = "$projectRoot\artifacts"
New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null
$target = "$artifactDirectory\deskhub-employee-release.apk"
Copy-Item -LiteralPath $source -Destination $target -Force
Get-FileHash -Algorithm SHA256 -LiteralPath $target
} finally {
Pop-Location
}
<#
.SYNOPSIS
Baut die signierte Release-App: AAB für den Play Store und APK für direkte Installation.
.DESCRIPTION
Fragt Keystore-Datei und Passwort ab (oder nimmt DESKHUB_RELEASE_* aus der Umgebung). Das Passwort bleibt nur in diesem Prozess.
Ergebnis: artifacts\deskhub-release.aab (Play Store) und artifacts\deskhub-employee-release.apk. Vorher versionCode in build.gradle.kts erhöhen.
#>
param([string]$StoreFile = (Join-Path $env:USERPROFILE 'deskhub-keys\deskhub-release.jks'), [string]$Alias = 'deskhub')
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_STORE_FILE)) { $env:DESKHUB_RELEASE_STORE_FILE = $StoreFile }
if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_KEY_ALIAS)) { $env:DESKHUB_RELEASE_KEY_ALIAS = $Alias }
if (-not (Test-Path -LiteralPath $env:DESKHUB_RELEASE_STORE_FILE -PathType Leaf)) { throw "Keystore nicht gefunden: $($env:DESKHUB_RELEASE_STORE_FILE). Erst .\new-keystore.ps1 ausführen." }
if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_STORE_PASSWORD)) {
$s = Read-Host 'Keystore-Passwort' -AsSecureString
$env:DESKHUB_RELEASE_STORE_PASSWORD = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))
}
if ([string]::IsNullOrWhiteSpace($env:DESKHUB_RELEASE_KEY_PASSWORD)) { $env:DESKHUB_RELEASE_KEY_PASSWORD = $env:DESKHUB_RELEASE_STORE_PASSWORD }
if (-not (Test-Path (Join-Path $PSScriptRoot 'google-services.json'))) { throw 'google-services.json fehlt im Ordner mobile (aus der Firebase-Konsole).' }
Push-Location $PSScriptRoot
try {
& "$PSScriptRoot\gradlew.bat" clean bundleRelease assembleRelease --no-daemon --console=plain
if ($LASTEXITCODE -ne 0) { throw "Release-Build fehlgeschlagen (Exitcode $LASTEXITCODE)." }
New-Item -ItemType Directory -Path artifacts -Force | Out-Null
$aab = Get-ChildItem -Recurse -Filter '*.aab' build\outputs\bundle\release | Select-Object -First 1
$apk = "$PSScriptRoot\build\outputs\apk\release\DeskHubMobile-release.apk"
if (-not $aab -or -not (Test-Path $apk)) { throw 'AAB oder APK wurde nicht erzeugt.' }
Copy-Item $aab.FullName artifacts\deskhub-release.aab -Force
Copy-Item $apk artifacts\deskhub-employee-release.apk -Force
Get-FileHash -Algorithm SHA256 artifacts\deskhub-release.aab, artifacts\deskhub-employee-release.apk
} finally {
Pop-Location
Remove-Item Env:\DESKHUB_RELEASE_STORE_PASSWORD, Env:\DESKHUB_RELEASE_KEY_PASSWORD -ErrorAction SilentlyContinue
}
+157 -154
View File
@@ -1,154 +1,157 @@
plugins {
id("com.android.application") version "8.4.2"
id("org.jetbrains.kotlin.android") version "1.9.24"
}
extra["author"] = "M_Viper"
extra["productName"] = "deskhub"
val releaseStoreFile = providers.environmentVariable("DESKHUB_RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.environmentVariable("DESKHUB_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("DESKHUB_RELEASE_KEY_ALIAS").orNull
val releaseKeyPassword = providers.environmentVariable("DESKHUB_RELEASE_KEY_PASSWORD").orNull
val releaseSigningConfigured = listOf(
releaseStoreFile,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword,
).all { !it.isNullOrBlank() }
android {
namespace = "de.deskhub.staff"
compileSdk = 34
defaultConfig {
applicationId = "de.deskhub.staff"
minSdk = 26
targetSdk = 34
// WICHTIG: Bei jedem neuen Debug-Build erhöhen. Android erkennt eine APK mit
// unverändertem versionCode (+ gleicher Signatur) auf manchen Geräten nicht
// zuverlässig als Update, wodurch eine Neuinstallation still fehlschlagen kann.
versionCode = 19
versionName = "2.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
signingConfigs {
if (releaseSigningConfigured) {
create("release") {
storeFile = file(requireNotNull(releaseStoreFile))
storePassword = requireNotNull(releaseStorePassword)
keyAlias = requireNotNull(releaseKeyAlias)
keyPassword = requireNotNull(releaseKeyPassword)
}
}
}
buildTypes {
release {
isMinifyEnabled = true
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.11.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.13")
testImplementation(composeBom)
testImplementation("androidx.compose.ui:ui-test-junit4")
testImplementation("androidx.test.ext:junit:1.2.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
val verifyBranding by tasks.registering {
doLast {
val strings = layout.projectDirectory
.file("src/main/res/values/strings.xml")
.asFile
.readText()
check("<string name=\"app_name\">deskhub</string>" in strings) {
"app_name muss exakt deskhub lauten."
}
val readme = layout.projectDirectory.file("README.md").asFile.readText()
check("Ersteller: M_Viper" in readme) {
"README.md muss das Feld Ersteller: M_Viper enthalten."
}
check(project.extra["author"] == "M_Viper") {
"Gradle-Projektmetadatum author muss M_Viper sein."
}
}
}
val exportDebugApk by tasks.registering {
doLast {
val source = layout.buildDirectory.file("outputs/apk/debug/DeskHubMobile-debug.apk").get().asFile
check(source.isFile) { "Debug-APK wurde nicht erzeugt: $source" }
val target = layout.projectDirectory.file("artifacts/deskhub-employee-debug.apk").asFile
target.parentFile.mkdirs()
source.copyTo(target, overwrite = true)
}
}
tasks.configureEach {
if (name == "preBuild") {
dependsOn(verifyBranding)
}
if (name == "assembleDebug") {
finalizedBy(exportDebugApk)
}
}
plugins {
id("com.android.application") version "8.4.2"
id("org.jetbrains.kotlin.android") version "1.9.24"
id("com.google.gms.google-services") version "4.4.4"
}
extra["author"] = "M_Viper"
extra["productName"] = "deskhub"
val releaseStoreFile = providers.environmentVariable("DESKHUB_RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.environmentVariable("DESKHUB_RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("DESKHUB_RELEASE_KEY_ALIAS").orNull
val releaseKeyPassword = providers.environmentVariable("DESKHUB_RELEASE_KEY_PASSWORD").orNull
val releaseSigningConfigured = listOf(
releaseStoreFile,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword,
).all { !it.isNullOrBlank() }
android {
namespace = "de.deskhub.staff"
compileSdk = 35
defaultConfig {
applicationId = "de.deskhub.staff"
minSdk = 26
targetSdk = 35
// WICHTIG: Bei jedem neuen Debug-Build erhöhen. Android erkennt eine APK mit
// unverändertem versionCode (+ gleicher Signatur) auf manchen Geräten nicht
// zuverlässig als Update, wodurch eine Neuinstallation still fehlschlagen kann.
versionCode = 26
versionName = "2.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
signingConfigs {
if (releaseSigningConfigured) {
create("release") {
storeFile = file(requireNotNull(releaseStoreFile))
storePassword = requireNotNull(releaseStorePassword)
keyAlias = requireNotNull(releaseKeyAlias)
keyPassword = requireNotNull(releaseKeyPassword)
}
}
}
buildTypes {
release {
isMinifyEnabled = true
if (releaseSigningConfigured) {
signingConfig = signingConfigs.getByName("release")
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
testOptions {
unitTests.isIncludeAndroidResources = true
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
implementation("androidx.navigation:navigation-compose:2.7.7")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.google.code.gson:gson:2.11.0")
// Push-Benachrichtigungen (Firebase Cloud Messaging)
implementation("com.google.firebase:firebase-messaging:25.0.1")
testImplementation("junit:junit:4.13.2")
testImplementation("org.robolectric:robolectric:4.13")
testImplementation(composeBom)
testImplementation("androidx.compose.ui:ui-test-junit4")
testImplementation("androidx.test.ext:junit:1.2.1")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
val verifyBranding by tasks.registering {
doLast {
val strings = layout.projectDirectory
.file("src/main/res/values/strings.xml")
.asFile
.readText()
check("<string name=\"app_name\">deskhub</string>" in strings) {
"app_name muss exakt deskhub lauten."
}
val readme = layout.projectDirectory.file("README.md").asFile.readText()
check("Ersteller: M_Viper" in readme) {
"README.md muss das Feld Ersteller: M_Viper enthalten."
}
check(project.extra["author"] == "M_Viper") {
"Gradle-Projektmetadatum author muss M_Viper sein."
}
}
}
val exportDebugApk by tasks.registering {
doLast {
val source = layout.buildDirectory.file("outputs/apk/debug/DeskHubMobile-debug.apk").get().asFile
check(source.isFile) { "Debug-APK wurde nicht erzeugt: $source" }
val target = layout.projectDirectory.file("artifacts/deskhub-employee-debug.apk").asFile
target.parentFile.mkdirs()
source.copyTo(target, overwrite = true)
}
}
tasks.configureEach {
if (name == "preBuild") {
dependsOn(verifyBranding)
}
if (name == "assembleDebug") {
finalizedBy(exportDebugApk)
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "436755419487",
"project_id": "deskhub-96d3f",
"storage_bucket": "deskhub-96d3f.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:436755419487:android:8bad822266b61db37466ea",
"android_client_info": {
"package_name": "de.deskhub.staff"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyBN6ud1flyG_aFJdv25fsFQpHpxpESiDlk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}
+1
View File
@@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.suppressUnsupportedCompileSdk=35
@@ -0,0 +1,125 @@
package de.deskhub.staff.push
import android.app.Application
import android.app.NotificationManager
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
/** Benachrichtigungen der Push-Nachrichten (auf der JVM mit Robolectric). */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DeskHubPushTest {
private val context: Application get() = ApplicationProvider.getApplicationContext()
private fun grantPermission() = shadowOf(context).grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
private fun manager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@Test
fun createsHighImportanceChannel() {
DeskHubPush.ensureChannel(context)
val channel = manager().getNotificationChannel(DeskHubPush.CHANNEL_TICKETS)
assertNotNull(channel)
assertEquals(NotificationManager.IMPORTANCE_HIGH, channel.importance)
}
@Test
fun showsNotificationWithTitleTextAndOpensTicket() {
grantPermission()
val shown = DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-123", "title" to "Neues Ticket", "body" to "#T-123"))
assertTrue(shown)
val posted = shadowOf(manager()).allNotifications
assertEquals(1, posted.size)
val n = posted[0]
assertEquals("Neues Ticket", n.extras.getCharSequence("android.title").toString())
assertEquals("#T-123", n.extras.getCharSequence("android.text").toString())
// Tipp öffnet die App mit der Ticketnummer
val intent = shadowOf(n.contentIntent).savedIntent
assertEquals("T-123", intent.getStringExtra(DeskHubPush.EXTRA_TICKET))
}
@Test
fun sameTicketReplacesEarlierNotification() {
grantPermission()
val data = mapOf("type" to "ticket_reply", "ticket" to "T-7", "title" to "Neue Antwort", "body" to "#T-7")
DeskHubPush.show(context, data)
DeskHubPush.show(context, data)
assertEquals(1, shadowOf(manager()).allNotifications.size)
DeskHubPush.show(context, data + ("ticket" to "T-8"))
assertEquals(2, shadowOf(manager()).allNotifications.size)
}
@Test
fun ignoresUnknownOrEmptyMessages() {
grantPermission()
assertFalse(DeskHubPush.show(context, mapOf("type" to "irgendwas", "title" to "x")))
assertFalse(DeskHubPush.show(context, mapOf("type" to "ticket_new", "title" to " ")))
assertFalse(DeskHubPush.show(context, emptyMap()))
assertEquals(0, shadowOf(manager()).allNotifications.size)
}
@Test
fun showsNothingWithoutPermission() {
shadowOf(context).denyPermissions(android.Manifest.permission.POST_NOTIFICATIONS)
assertFalse(DeskHubPush.canNotify(context))
assertFalse(DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-1", "title" to "Neues Ticket")))
assertEquals(0, shadowOf(manager()).allNotifications.size)
}
@Test
fun customerReplyHasQuickReplyActionOthersDoNot() {
grantPermission()
DeskHubPush.show(context, mapOf("type" to "ticket_reply", "ticket" to "T-5", "title" to "Neue Antwort", "body" to "#T-5"))
DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-6", "title" to "Neues Ticket", "body" to "#T-6"))
val all = shadowOf(manager()).allNotifications
val reply = all.first { it.extras.getCharSequence("android.text").toString() == "#T-5" }
val other = all.first { it.extras.getCharSequence("android.text").toString() == "#T-6" }
assertEquals(1, reply.actions.size)
assertEquals("Antworten", reply.actions[0].title.toString())
assertNotNull(reply.actions[0].remoteInputs)
assertEquals(PushReplyReceiver.KEY_REPLY, reply.actions[0].remoteInputs[0].resultKey)
assertTrue(other.actions == null || other.actions.isEmpty())
}
@Test
fun newEventTypesAreShown() {
grantPermission()
assertTrue(DeskHubPush.show(context, mapOf("type" to "ticket_sla", "ticket" to "T-9", "title" to "SLA überschritten", "body" to "#T-9")))
assertTrue(DeskHubPush.show(context, mapOf("type" to "ticket_mention", "ticket" to "T-9", "title" to "Erwähnung", "body" to "#T-9")))
assertEquals(2, shadowOf(manager()).allNotifications.size)
}
@Test
fun brandNameShowsNextToAppName() {
grantPermission()
DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-20", "title" to "Neues Ticket", "body" to "#T-20", "brand" to "Windel Support"))
DeskHubPush.show(context, mapOf("type" to "ticket_new", "ticket" to "T-21", "title" to "Neues Ticket", "body" to "#T-21"))
val all = shadowOf(manager()).allNotifications
val withBrand = all.first { it.extras.getCharSequence("android.text").toString() == "#T-20" }
val without = all.first { it.extras.getCharSequence("android.text").toString() == "#T-21" }
assertEquals("Windel Support", withBrand.extras.getCharSequence("android.subText").toString())
assertTrue(without.extras.getCharSequence("android.subText") == null)
}
@Test
fun quickReplyTextIsEscapedForHtml() {
assertEquals("a &lt;b&gt; &amp; c<br>d", PushReplyReceiver.toHtml("a <b> & c\nd"))
}
@Test
fun notificationIdsDifferPerTicketAndType() {
assertTrue(DeskHubPush.notificationId("ticket_new", "T-1") != DeskHubPush.notificationId("ticket_new", "T-2"))
assertTrue(DeskHubPush.notificationId("ticket_new", "T-1") != DeskHubPush.notificationId("ticket_reply", "T-1"))
assertEquals(DeskHubPush.notificationId("ticket_new", "T-1"), DeskHubPush.notificationId("ticket_new", "T-1"))
assertTrue(DeskHubPush.notificationId("ticket_new", "T-1") >= 0)
}
}