From ec7c298967d990cd629ee0249e9137d30c72a159 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 08:26:03 +0100 Subject: [PATCH 01/12] Update Android release workflow for debug builds --- .github/workflows/android-release.yml | 76 +++++++-------------------- 1 file changed, 20 insertions(+), 56 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 5290307..80f5e25 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -1,60 +1,38 @@ -name: Android Release +name: Build Android APK on: push: - branches: [ "main" ] - workflow_dispatch: {} - -permissions: - contents: write # notwendig, um Releases/Tags anzulegen + branches: [ dev ] + pull_request: + branches: [ main ] jobs: - build-release: + build: + name: Build APK runs-on: ubuntu-latest - env: - # Pfade an dein Modul anpassen, falls nicht "app" - APP_MODULE: app - steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 - - name: Setup JDK 17 - uses: actions/setup-java@v4 + - name: Set up JDK + uses: actions/setup-java@v3 with: - distribution: temurin - java-version: "17" - cache: gradle - - - name: Setup Android SDK - uses: android-actions/setup-android@v3 - - - name: Decode signing keystore - if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} - shell: bash - env: - ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - run: | - echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > keystore.jks - echo "ANDROID_KEYSTORE_PATH=$GITHUB_WORKSPACE/keystore.jks" >> $GITHUB_ENV + distribution: 'temurin' + java-version: '17' - - name: Make gradlew executable - run: chmod +x gradlew + - name: Setup Gradle cache + uses: gradle/gradle-build-action@v3 # ---- Version aus Gradle lesen ---- - name: Read versionName from Gradle id: ver run: | - VERSION_NAME=$(./gradlew -q :${{ env.APP_MODULE }}:printVersionName) + VERSION_NAME=$(./gradlew -q :app:printVersionName) echo "version_name=$VERSION_NAME" >> $GITHUB_OUTPUT - # ---- Release Build (APK & AAB) ---- - - name: Build Release APK - run: ./gradlew :${{ env.APP_MODULE }}:assembleRelease - - - name: Build Release AAB - run: ./gradlew :${{ env.APP_MODULE }}:bundleRelease + - name: Build debug APK + run: ./gradlew assembleDebug # ---- Artefakte umbenennen ---- - name: Rename outputs to the correct version @@ -63,14 +41,14 @@ jobs: set -e VER="${{ steps.ver.outputs.version_name }}" - APK_SRC="${{ env.APP_MODULE }}/build/outputs/apk/release" - AAB_SRC="${{ env.APP_MODULE }}/build/outputs/bundle/release" + APK_SRC="app/build/outputs/apk/debug" + AAB_SRC="app/build/outputs/bundle/debug" APK_PATH=$(ls "$APK_SRC"/*.apk | head -n1) AAB_PATH=$(ls "$AAB_SRC"/*.aab | head -n1) - APK_OUT="ipv64net_v${VER}.apk" - AAB_OUT="ipv64net_v${VER}.aab" + APK_OUT="ipv64net_v${VER}-debug.apk" + AAB_OUT="ipv64net_v${VER}-debug.aab" cp "$APK_PATH" "$APK_OUT" cp "$AAB_PATH" "$AAB_OUT" @@ -87,17 +65,3 @@ jobs: path: | ${{ steps.rename.outputs.apk }} ${{ steps.rename.outputs.aab }} - - # ---- GitHub Release erzeugen + Dateien anhängen ---- - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.rename.outputs.version }} - name: "ipv64net v${{ steps.rename.outputs.version }}" - draft: false - prerelease: false - files: | - ${{ steps.rename.outputs.apk }} - ${{ steps.rename.outputs.aab }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From 0cb9c0cb4330ee143a4d60a9dd7565c0cddd3466 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 08:34:44 +0100 Subject: [PATCH 02/12] Disable AAB output in android-release workflow Comment out AAB-related lines in the workflow. --- .github/workflows/android-release.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 80f5e25..7d13443 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -42,19 +42,19 @@ jobs: VER="${{ steps.ver.outputs.version_name }}" APK_SRC="app/build/outputs/apk/debug" - AAB_SRC="app/build/outputs/bundle/debug" + # AAB_SRC="app/build/outputs/bundle/debug" APK_PATH=$(ls "$APK_SRC"/*.apk | head -n1) - AAB_PATH=$(ls "$AAB_SRC"/*.aab | head -n1) + # AAB_PATH=$(ls "$AAB_SRC"/*.aab | head -n1) APK_OUT="ipv64net_v${VER}-debug.apk" - AAB_OUT="ipv64net_v${VER}-debug.aab" + # AAB_OUT="ipv64net_v${VER}-debug.aab" cp "$APK_PATH" "$APK_OUT" - cp "$AAB_PATH" "$AAB_OUT" + # cp "$AAB_PATH" "$AAB_OUT" echo "apk=$APK_OUT" >> $GITHUB_OUTPUT - echo "aab=$AAB_OUT" >> $GITHUB_OUTPUT + # echo "aab=$AAB_OUT" >> $GITHUB_OUTPUT echo "version=$VER" >> $GITHUB_OUTPUT # ---- Als Build-Artefakt hochladen (optional, nützlich für CI-Download) ---- @@ -64,4 +64,4 @@ jobs: name: ipv64net-${{ steps.rename.outputs.version }} path: | ${{ steps.rename.outputs.apk }} - ${{ steps.rename.outputs.aab }} + # ${{ steps.rename.outputs.aab }} From cdb071f620f36a4c6a281871de9fde507b2f5a00 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 08:46:23 +0100 Subject: [PATCH 03/12] Enhance Android release workflow with signing steps Added steps to decode keystore, build, sign, and align APK. --- .github/workflows/android-release.yml | 31 ++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 7d13443..d399eb5 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -61,7 +61,36 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: ipv64net-${{ steps.rename.outputs.version }} + name: ipv64net-${{ steps.rename.outputs.version }}-debug path: | ${{ steps.rename.outputs.apk }} # ${{ steps.rename.outputs.aab }} + + - name: Decode keystore + if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }} + run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > releaseKey.jks + + - name: Build release APK + run: ./gradlew assembleRelease + + - name: Sign APK + run: | + jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ + -keystore releaseKey.jks -storepass ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \ + -keypass ${{ secrets.ANDROID_KEY_PASSWORD }} \ + app/build/outputs/apk/release/app-release-unsigned.apk ${{ secrets.ANDROID_KEY_ALIAS }} + + - name: Verify signature + run: jarsigner -verify -verbose -certs app/build/outputs/apk/release/app-release-unsigned.apk + + - name: Align APK + run: | + $ANDROID_HOME/build-tools/34.0.0/zipalign -v 4 \ + app/build/outputs/apk/release/app-release-unsigned.apk \ + app/build/outputs/apk/release/app-release.apk + + - name: Upload Signed APK + uses: actions/upload-artifact@v3 + with: + name: ipv64net-${{ steps.rename.outputs.version }}-signed + path: app/build/outputs/apk/release/app-release.apk From 03e0b2e3ef36a93bc988283cafcda9bcecfc7bb3 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 08:48:58 +0100 Subject: [PATCH 04/12] Update keystore decoding to use environment variable --- .github/workflows/android-release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index d399eb5..1ef70dd 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -67,8 +67,10 @@ jobs: # ${{ steps.rename.outputs.aab }} - name: Decode keystore - if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }} - run: echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > releaseKey.jks + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > releaseKey.jks - name: Build release APK run: ./gradlew assembleRelease From 195b771a5b6efb5072f1a5feb9aad63ae26ecead Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 08:50:00 +0100 Subject: [PATCH 05/12] Upgrade upload-artifact action to version 4 --- .github/workflows/android-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 1ef70dd..8b6ea98 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -92,7 +92,7 @@ jobs: app/build/outputs/apk/release/app-release.apk - name: Upload Signed APK - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ipv64net-${{ steps.rename.outputs.version }}-signed path: app/build/outputs/apk/release/app-release.apk From 6bec2994ea99591ff6fdd785d5879f6a07008740 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 10:02:51 +0100 Subject: [PATCH 06/12] Enhance Android release workflow with AAB support Updated Gradle action versions and added AAB build steps. --- .github/workflows/android-release.yml | 65 ++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 8b6ea98..68c4762 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -22,7 +22,7 @@ jobs: java-version: '17' - name: Setup Gradle cache - uses: gradle/gradle-build-action@v3 + uses: gradle/actions/setup-gradle@v5 # ---- Version aus Gradle lesen ---- - name: Read versionName from Gradle @@ -34,6 +34,9 @@ jobs: - name: Build debug APK run: ./gradlew assembleDebug + - name: Build debug AAB + run: ./gradlew bundleDebug + # ---- Artefakte umbenennen ---- - name: Rename outputs to the correct version id: rename @@ -42,19 +45,19 @@ jobs: VER="${{ steps.ver.outputs.version_name }}" APK_SRC="app/build/outputs/apk/debug" - # AAB_SRC="app/build/outputs/bundle/debug" + AAB_SRC="app/build/outputs/bundle/debug" APK_PATH=$(ls "$APK_SRC"/*.apk | head -n1) - # AAB_PATH=$(ls "$AAB_SRC"/*.aab | head -n1) + AAB_PATH=$(ls "$AAB_SRC"/*.aab | head -n1) APK_OUT="ipv64net_v${VER}-debug.apk" - # AAB_OUT="ipv64net_v${VER}-debug.aab" + AAB_OUT="ipv64net_v${VER}-debug.aab" cp "$APK_PATH" "$APK_OUT" - # cp "$AAB_PATH" "$AAB_OUT" + cp "$AAB_PATH" "$AAB_OUT" echo "apk=$APK_OUT" >> $GITHUB_OUTPUT - # echo "aab=$AAB_OUT" >> $GITHUB_OUTPUT + echo "aab=$AAB_OUT" >> $GITHUB_OUTPUT echo "version=$VER" >> $GITHUB_OUTPUT # ---- Als Build-Artefakt hochladen (optional, nützlich für CI-Download) ---- @@ -64,7 +67,7 @@ jobs: name: ipv64net-${{ steps.rename.outputs.version }}-debug path: | ${{ steps.rename.outputs.apk }} - # ${{ steps.rename.outputs.aab }} + ${{ steps.rename.outputs.aab }} - name: Decode keystore if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} @@ -75,6 +78,9 @@ jobs: - name: Build release APK run: ./gradlew assembleRelease + - name: Build release AAB + run: ./gradlew bundleRelease + - name: Sign APK run: | jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ @@ -91,8 +97,47 @@ jobs: app/build/outputs/apk/release/app-release-unsigned.apk \ app/build/outputs/apk/release/app-release.apk - - name: Upload Signed APK + # ======= AAB signieren (falls NICHT schon durch Gradle gesigned) ======= + # Wenn du signingConfigs.release in Gradle mit releaseKey.jks verwendest, + # ist das AAB bereits signiert und du kannst diesen Schritt weg lassen. + - name: Sign AAB (optional) + if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }} + run: | + jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ + -keystore releaseKey.jks -storepass ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \ + -keypass ${{ secrets.ANDROID_KEY_PASSWORD }} \ + app/build/outputs/bundle/release/app-release.aab ${{ secrets.ANDROID_KEY_ALIAS }} + + - name: Verify AAB signature + run: jarsigner -verify -verbose -certs app/build/outputs/bundle/release/app-release.aab + + # ---- Artefakte umbenennen ---- + - name: Rename outputs to the correct version + id: renamesign + run: | + set -e + VER="${{ steps.ver.outputs.version_name }}" + + APK_SRC="app/build/outputs/apk/release" + AAB_SRC="app/build/outputs/bundle/release" + + APK_PATH=$(ls "$APK_SRC"/*release.apk | head -n1) + AAB_PATH=$(ls "$AAB_SRC"/*release.aab | head -n1) + + APK_OUT="ipv64net_v${VER}-signed.apk" + AAB_OUT="ipv64net_v${VER}-signed.aab" + + cp "$APK_PATH" "$APK_OUT" + cp "$AAB_PATH" "$AAB_OUT" + + echo "apk=$APK_OUT" >> $GITHUB_OUTPUT + echo "aab=$AAB_OUT" >> $GITHUB_OUTPUT + echo "version=$VER" >> $GITHUB_OUTPUT + + - name: Upload Signed APK & AAB uses: actions/upload-artifact@v4 with: - name: ipv64net-${{ steps.rename.outputs.version }}-signed - path: app/build/outputs/apk/release/app-release.apk + name: ipv64net-${{ steps.renamesign.outputs.version }}-signed + path: | + ${{ steps.renamesign.outputs.apk }} + ${{ steps.renamesign.outputs.aab }} From 8965a6d0b27887e07e7482a4dd8c706c52b0aa24 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 10:04:05 +0100 Subject: [PATCH 07/12] Fix condition for AAB signing step in workflow --- .github/workflows/android-release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 68c4762..d47e63c 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -101,7 +101,9 @@ jobs: # Wenn du signingConfigs.release in Gradle mit releaseKey.jks verwendest, # ist das AAB bereits signiert und du kannst diesen Schritt weg lassen. - name: Sign AAB (optional) - if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }} + if: ${{ env.ANDROID_KEYSTORE_BASE64 != '' }} + env: + ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} run: | jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \ -keystore releaseKey.jks -storepass ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \ From b6a3fb30c5ef716b6f5bd770dc42352ad2be6bd4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 10:14:45 +0100 Subject: [PATCH 08/12] Create GitHub Release for signed APK and AAB Add step to create a GitHub Release with APK and AAB files. --- .github/workflows/android-release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index d47e63c..f21d99f 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -143,3 +143,17 @@ jobs: path: | ${{ steps.renamesign.outputs.apk }} ${{ steps.renamesign.outputs.aab }} + + # ---- GitHub Release erzeugen + Dateien anhängen ---- + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.renamesign.outputs.version }} + name: "ipv64net v${{ steps.renamesign.outputs.version }}" + draft: true + prerelease: false + files: | + ${{ steps.renamesign.outputs.apk }} + ${{ steps.renamesign.outputs.aab }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From eea7f1d0fc7482b0248852099ad25009cfe0383d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 10:29:41 +0100 Subject: [PATCH 09/12] Change trigger branch from 'dev' to 'main' --- .github/workflows/android-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index f21d99f..269c6db 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -2,7 +2,7 @@ name: Build Android APK on: push: - branches: [ dev ] + branches: [ main ] pull_request: branches: [ main ] From 7463b2dcc7a18e2309ec34e65b4a41af94a600a7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 2 Dec 2025 13:34:08 +0100 Subject: [PATCH 10/12] Version 2.0.1 (21) - added mutliuser feature #17 - added pause event when a healthcheck is paused (indication) #27 - updated libraries - preperation for f-droid #25 --- app/build.gradle.kts | 4 +- .../de/rpicloud/ipv64net/helper/Extensions.kt | 5 + .../ipv64net/helper/PreferencesManager.kt | 6 +- .../ipv64net/main/activity/MainActivity.kt | 2 + .../main/startup/activity/LoginActivity.kt | 2 + .../ipv64net/main/startup/views/LoginView.kt | 16 ++- .../ipv64net/main/views/HealthcheckView.kt | 12 +++ .../java/de/rpicloud/ipv64net/models/User.kt | 98 +++++++++++++++++++ gradle/libs.versions.toml | 22 ++--- 9 files changed, 149 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/de/rpicloud/ipv64net/models/User.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6ef2701..f60e5bf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "de.rpicloud.ipv64net" minSdk = 28 targetSdk = 36 - versionCode = 20 - versionName = "2.0.0" + versionCode = 21 + versionName = "2.0.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/de/rpicloud/ipv64net/helper/Extensions.kt b/app/src/main/java/de/rpicloud/ipv64net/helper/Extensions.kt index a5069e7..04f00b8 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/helper/Extensions.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/helper/Extensions.kt @@ -28,6 +28,11 @@ fun Date.formatGermanTime(): String { return format.format(this) } +fun Date.formatDbTime(): String { + val format = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.GERMANY) + return format.format(this) +} + fun String.parseDbDate(): String { val dbFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) val date: Date? = runCatching { dbFormat.parse(this) }.getOrNull() diff --git a/app/src/main/java/de/rpicloud/ipv64net/helper/PreferencesManager.kt b/app/src/main/java/de/rpicloud/ipv64net/helper/PreferencesManager.kt index 6a5e8d4..23cb701 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/helper/PreferencesManager.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/helper/PreferencesManager.kt @@ -9,7 +9,7 @@ class PreferencesManager { companion object { @SuppressLint("UseKtx") - inline fun saveList(ctx: Context, key: String, list: List) { + inline fun saveList(ctx: Context, key: String, list: MutableList) { val gson = Gson() val jsonText = gson.toJson(list) with(ctx.getSharedPreferences(key, Context.MODE_PRIVATE).edit()) { @@ -58,11 +58,11 @@ class PreferencesManager { } } - inline fun loadList(ctx: Context, key: String): List { + inline fun loadList(ctx: Context, key: String): MutableList { val gson = Gson() val preferences = ctx.getSharedPreferences(key, Context.MODE_PRIVATE) val jsonText = preferences.getString(key, "[]") - val type = object : TypeToken>() {}.type + val type = object : TypeToken>() {}.type return gson.fromJson(jsonText, type) } diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt b/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt index 730233a..ae050ca 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt @@ -56,6 +56,7 @@ import de.rpicloud.ipv64net.main.views.SettingsView import de.rpicloud.ipv64net.models.Tab import de.rpicloud.ipv64net.models.Tabs import de.rpicloud.ipv64net.models.Tabs.Companion.AddItem +import de.rpicloud.ipv64net.models.User import de.rpicloud.ipv64net.ui.theme.AppTheme class MainActivity : AppCompatActivity() { @@ -66,6 +67,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + User.init(applicationContext) enableEdgeToEdge() setContent { val navController = rememberNavController() diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/startup/activity/LoginActivity.kt b/app/src/main/java/de/rpicloud/ipv64net/main/startup/activity/LoginActivity.kt index 8748070..5f3227c 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/startup/activity/LoginActivity.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/startup/activity/LoginActivity.kt @@ -8,11 +8,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.navigation.compose.rememberNavController +import de.rpicloud.ipv64net.models.User import de.rpicloud.ipv64net.ui.theme.AppTheme class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + User.init(applicationContext) setContent { val navController = rememberNavController() AppTheme { diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt index 025f306..c5f2480 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt @@ -45,6 +45,7 @@ import de.rpicloud.ipv64net.helper.findActivity import de.rpicloud.ipv64net.helper.views.QRCodeDialogView import de.rpicloud.ipv64net.helper.views.ShowPermissionDialog import de.rpicloud.ipv64net.main.activity.MainActivity +import de.rpicloud.ipv64net.models.User @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable @@ -86,7 +87,7 @@ fun LoginView(navController: NavHostController) { } else { readExternalStoragePermissionState.launchPermissionRequest() } - apiKey = PreferencesManager.loadString(context, "APIKEY") + apiKey = "" } Scaffold( @@ -182,6 +183,18 @@ fun LoginView(navController: NavHostController) { Column(modifier = Modifier.padding(bottom = 32.dp)) { Button( onClick = { + val isContains = User.list.find { it.ApiKey == apiKey } != null + + if (isContains) { + return@Button + } + + val user = User.empty + user.ApiKey = apiKey + user.Username = if (User.list.count() > 0) "Default User ${User.list.count()}" else "Default User" + user.Information = "" + user.save() + PreferencesManager.saveString(context, "APIKEY", apiKey) val activity = context.findActivity() val intent = Intent(activity, MainActivity::class.java) @@ -219,7 +232,6 @@ fun LoginView(navController: NavHostController) { hasHandledResult = true showDialog = false apiKey = it - PreferencesManager.saveString(context, "APIKEY", it) } } ) diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/views/HealthcheckView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/HealthcheckView.kt index 15b4b40..e6b50fd 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/views/HealthcheckView.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/HealthcheckView.kt @@ -49,12 +49,14 @@ import androidx.navigation.NavHostController import com.google.gson.Gson import de.rpicloud.ipv64net.R import de.rpicloud.ipv64net.helper.NetworkService +import de.rpicloud.ipv64net.helper.formatDbTime import de.rpicloud.ipv64net.helper.v64domains import de.rpicloud.ipv64net.helper.views.ErrorDialog import de.rpicloud.ipv64net.helper.views.RequestDialogs import de.rpicloud.ipv64net.helper.views.SpinnerDialog import de.rpicloud.ipv64net.models.DomainResult import de.rpicloud.ipv64net.models.HealthCheckResult +import de.rpicloud.ipv64net.models.HealthEvents import de.rpicloud.ipv64net.models.RequestTyp import de.rpicloud.ipv64net.models.StatusType import de.rpicloud.ipv64net.models.Tab @@ -64,6 +66,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.util.Date @OptIn(ExperimentalMaterial3Api::class) @SuppressLint("ContextCastToActivity") @@ -104,6 +107,15 @@ fun HealthcheckView(navController: NavHostController, mainPadding: PaddingValues } else { (nwResult.data as HealthCheckResult).also { healthCheckResult = it } val sortedList = healthCheckResult.domain.sortedBy { it.name.lowercase() } + + val newEvent = HealthEvents(event_time = Date().formatDbTime(), status = StatusType.Pause.type.statusId!!, text = "Pause active") + + sortedList.forEach { hc -> + if (hc.HealthStatus == StatusType.Pause.type) { + hc.events.add(0, newEvent) + } + } + healthCheckResult.domain = sortedList.toMutableList() println(healthCheckResult) activeCount = healthCheckResult.domain.filter { hc -> hc.HealthStatus == StatusType.Active.type }.size diff --git a/app/src/main/java/de/rpicloud/ipv64net/models/User.kt b/app/src/main/java/de/rpicloud/ipv64net/models/User.kt new file mode 100644 index 0000000..cd2f2e6 --- /dev/null +++ b/app/src/main/java/de/rpicloud/ipv64net/models/User.kt @@ -0,0 +1,98 @@ +package de.rpicloud.ipv64net.models + +import android.content.Context +import de.rpicloud.ipv64net.helper.PreferencesManager +import java.util.UUID + +data class User( + var id: UUID = UUID.randomUUID(), + var Username: String = "", + var ApiKey: String, + var Information: String +) { + companion object { + private lateinit var appContext: Context + + fun init(context: Context) { + appContext = context.applicationContext + val apiKey = PreferencesManager.loadString(appContext, "APIKEY") + if (apiKey.isNotEmpty()) { + val user = User(Username = "Default User", ApiKey = apiKey, Information = "") + list.add(user) + saveList(list) + } + } + + val empty = User(Username = "", ApiKey = "", Information = "") + + val current: User? + get() { + if (list.isEmpty()) { + return null + } + + val user = list.firstOrNull { it.ApiKey == PreferencesManager.loadString(appContext, "APIKEY") } + + if (user != null && user.Information.isEmpty()) { + user.Information = "No Information" + } + return user + } + + val list: MutableList + get() { + val data = PreferencesManager.loadList(ctx = appContext, key = "APP_USERS") + var userList = mutableListOf() + + try { + if (data.isNotEmpty()) { + userList = data + + var isNilUUID = false + + userList = userList.map { u -> + if (u.id == null) { + isNilUUID = true + u.copy(id = UUID.randomUUID()) + } else u + }.toMutableList() + + if (isNilUUID) { + saveList(userList) + } + } + } catch (e: Exception) { + println("Error decoding data: ${e.message}") + } + + return userList + } + + fun saveList(list: MutableList) { + PreferencesManager.saveList(ctx = appContext, key = "APP_USERS", list) + } + } + + fun save() { + val userList = list + userList.add(this) + saveList(userList) + } + + fun update() { + val userList = list + val index = userList.indexOfFirst { it.id == this.id } + if (index != -1) { + userList[index] = this + saveList(userList) + } + } + + fun delete() { + val userList = list + userList.removeAll { it.ApiKey == current?.ApiKey } + + saveList(userList) + PreferencesManager.saveString(appContext, "APIKEY", userList.first().ApiKey) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e3fb92b..5c9c659 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,28 +1,28 @@ [versions] -agp = "8.13.0" -kotlin = "2.2.20" +agp = "8.13.1" +kotlin = "2.2.21" coreKtx = "1.17.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -lifecycleRuntimeKtx = "2.9.4" -activityCompose = "1.11.0" -composeBom = "2025.10.00" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.12.0" +composeBom = "2025.11.01" appcompat = "1.7.1" gson = "2.13.2" -navigationCompose = "2.9.5" +navigationCompose = "2.9.6" library = "1.1.1" -core = "3.5.3" -loggingInterceptor = "5.2.1" -okhttp = "5.2.1" +core = "3.5.4" +loggingInterceptor = "5.3.2" +okhttp = "5.3.2" cameraCamera2 = "1.5.1" cameraCore = "1.5.1" cameraLifecycle = "1.5.1" cameraView = "1.5.1" accompanistPermissions = "0.37.3" biometric = "1.4.0-alpha04" -datastorePreferences = "1.2.0-alpha02" -lifecycleProcess = "2.9.4" +datastorePreferences = "1.2.0" +lifecycleProcess = "2.10.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } From 5028d7e07a4006370929f6281658c48a05358680 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 3 Dec 2025 12:06:23 +0100 Subject: [PATCH 11/12] Mutliaccount support - added AccountView for switching users or add new user - Settings -> Account -> User Button to show Accountlist - Changing user by clicking --- .../ipv64net/main/activity/MainActivity.kt | 8 + .../ipv64net/main/startup/views/LoginView.kt | 18 ++- .../ipv64net/main/views/AccountDetailView.kt | 148 ++++++++++++++++++ .../ipv64net/main/views/AccountView.kt | 82 ++++++++-- .../java/de/rpicloud/ipv64net/models/Tabs.kt | 2 + .../java/de/rpicloud/ipv64net/models/User.kt | 3 +- app/src/main/res/drawable/person_add_24px.xml | 10 ++ 7 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt create mode 100644 app/src/main/res/drawable/person_add_24px.xml diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt b/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt index ae050ca..25e7fda 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt @@ -38,7 +38,9 @@ import androidx.navigation.compose.rememberNavController import de.rpicloud.ipv64net.R import de.rpicloud.ipv64net.helper.BiometricPromptManager import de.rpicloud.ipv64net.helper.PreferencesManager +import de.rpicloud.ipv64net.main.startup.views.LoginView import de.rpicloud.ipv64net.main.views.AboutView +import de.rpicloud.ipv64net.main.views.AccountDetailView import de.rpicloud.ipv64net.main.views.AccountView import de.rpicloud.ipv64net.main.views.DomainDetailView import de.rpicloud.ipv64net.main.views.DomainDnsNewView @@ -119,6 +121,9 @@ class MainActivity : AppCompatActivity() { composable(Tabs.Companion.getRoute(Tab.account)) { AccountView(navController, mainPadding = mainPadding) } + composable(Tabs.Companion.getRoute(Tab.account_details)) { + AccountDetailView(navController, mainPadding = mainPadding) + } composable(Tabs.Companion.getRoute(Tab.logs)) { LogView(navController, mainPadding = mainPadding) } @@ -128,6 +133,9 @@ class MainActivity : AppCompatActivity() { composable(Tabs.Companion.getRoute(Tab.about)) { AboutView(navController, mainPadding = mainPadding) } + composable(Tabs.Companion.getRoute(Tab.login)) { + LoginView(navController, true) + } } } } else { diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt index c5f2480..0c10162 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt @@ -49,7 +49,7 @@ import de.rpicloud.ipv64net.models.User @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable -fun LoginView(navController: NavHostController) { +fun LoginView(navController: NavHostController, isFromUser: Boolean = false) { val context = LocalContext.current var showDialog by remember { mutableStateOf(false) } @@ -195,12 +195,16 @@ fun LoginView(navController: NavHostController) { user.Information = "" user.save() - PreferencesManager.saveString(context, "APIKEY", apiKey) - val activity = context.findActivity() - val intent = Intent(activity, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - activity?.startActivity(intent) - activity?.finish() + if (!isFromUser) { + PreferencesManager.saveString(context, "APIKEY", apiKey) + val activity = context.findActivity() + val intent = Intent(activity, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + activity?.startActivity(intent) + activity?.finish() + } else { + navController.popBackStack() + } }, enabled = !apiKey.isEmpty(), modifier = Modifier diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt new file mode 100644 index 0000000..cc6989a --- /dev/null +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt @@ -0,0 +1,148 @@ +package de.rpicloud.ipv64net.main.views + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import de.rpicloud.ipv64net.R +import de.rpicloud.ipv64net.helper.PreferencesManager +import de.rpicloud.ipv64net.models.Tab +import de.rpicloud.ipv64net.models.Tabs +import de.rpicloud.ipv64net.models.User + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountDetailView(navController: NavHostController, mainPadding: PaddingValues) { + + val ctx = LocalContext.current + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + var fabVisible by remember { mutableStateOf(true) } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + painter = painterResource(id = R.drawable.arrow_back_24px), + contentDescription = "Close" + ) + } + }, title = { + Text("Users") + }, modifier = Modifier.statusBarsPadding() + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = fabVisible, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + FloatingActionButton(onClick = { + navController.navigate(Tabs.getRoute(Tab.login)) + }) { + Icon( + painter = painterResource(id = R.drawable.person_add_24px), + contentDescription = "icon" + ) + } + } + }, + modifier = Modifier + .fillMaxSize() + .padding(mainPadding) + .consumeWindowInsets(mainPadding) // avoids double insets + ) { + Column( + modifier = Modifier + .padding(it) + .fillMaxHeight() + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + User.list.forEach { user -> + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable(onClick = { + // Action to perform + PreferencesManager.saveString(ctx, "APIKEY", user.ApiKey) + navController.popBackStack() + }).padding(16.dp) + ) { + Column { + Text( + user.Username, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + user.Information.ifEmpty { "No Information" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.weight(1f)) + RadioButton( + selected = User.current!!.ApiKey == user.ApiKey, + onClick = { } + ) + } + } + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt index dbcea99..544a067 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt @@ -8,13 +8,17 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,14 +36,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController import de.rpicloud.ipv64net.R import de.rpicloud.ipv64net.helper.NetworkService import de.rpicloud.ipv64net.helper.apiUsageText @@ -47,10 +52,13 @@ import de.rpicloud.ipv64net.helper.parseDbDate import de.rpicloud.ipv64net.helper.views.ErrorDialog import de.rpicloud.ipv64net.helper.views.RequestDialogs import de.rpicloud.ipv64net.helper.views.SpinnerDialog +import de.rpicloud.ipv64net.main.activity.TabView import de.rpicloud.ipv64net.models.AccountInfo import de.rpicloud.ipv64net.models.RequestTyp import de.rpicloud.ipv64net.models.Tab import de.rpicloud.ipv64net.models.Tabs +import de.rpicloud.ipv64net.models.User +import de.rpicloud.ipv64net.ui.theme.AppTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.zhanghai.compose.preference.ProvidePreferenceLocals @@ -112,22 +120,23 @@ fun AccountView(navController: NavHostController, mainPadding: PaddingValues) { } 403 -> { - requestType = if ((nwResult.data as String).contains("domain limit reached")) { - RequestTyp.DomainLimitReached - } - else if ((nwResult.data as String).contains("domainname not available")) - RequestTyp.DomainNotAvailable - else - RequestTyp.DomainRulesNotCreated + requestType = + if ((nwResult.data as String).contains("domain limit reached")) { + RequestTyp.DomainLimitReached + } else if ((nwResult.data as String).contains("domainname not available")) + RequestTyp.DomainNotAvailable + else + RequestTyp.DomainRulesNotCreated showRequestDialog = true } 429 -> { - requestType = if ((nwResult.data as String).contains("Updateintervall overcommited")) { - RequestTyp.UpdateCoolDown - } else - RequestTyp.TooManyRequests + requestType = + if ((nwResult.data as String).contains("Updateintervall overcommited")) { + RequestTyp.UpdateCoolDown + } else + RequestTyp.TooManyRequests showRequestDialog = true } @@ -175,6 +184,41 @@ fun AccountView(navController: NavHostController, mainPadding: PaddingValues) { contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + User.current?.let { user -> + item { + Button(onClick = { + navController.navigate(Tabs.getRoute(Tab.account_details)) + }) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Icon( + painter = painterResource(id = R.drawable.account_circle_24px), + contentDescription = "Account Icon", + modifier = Modifier + .width(55.dp) + .height(55.dp) + ) + Column { + Text( + user.Username, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + user.Information, + modifier = Modifier.padding(start = 8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } stickyHeader { Surface( // nimmt Theme-Hintergrund, hebt Text hervor tonalElevation = 2.dp @@ -385,4 +429,16 @@ fun AccountView(navController: NavHostController, mainPadding: PaddingValues) { LaunchedEffect(Unit) { getAccountInfos() } +} + +@Preview(showBackground = true, device = "id:pixel_5") +@Composable +fun AccountViewPreview() { + AppTheme { + val navController = rememberNavController() + User.init(context = LocalContext.current) + Scaffold(bottomBar = { TabView(Tabs.tabList, navController) }) { mainPadding -> + AccountView(navController, mainPadding) + } + } } \ No newline at end of file diff --git a/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt b/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt index 625268d..dd068b7 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt @@ -30,6 +30,7 @@ enum class Tab { integrations, settings, account, + account_details, logs, my_ip, about @@ -88,6 +89,7 @@ sealed class Tabs { Tab.integrations -> "integrations" Tab.settings -> "settings" Tab.account -> "account" + Tab.account_details -> "account_details" Tab.domain_details -> "domain_details" Tab.domain_new -> "domain_new" Tab.domain_new_dns -> "domain_new_dns" diff --git a/app/src/main/java/de/rpicloud/ipv64net/models/User.kt b/app/src/main/java/de/rpicloud/ipv64net/models/User.kt index cd2f2e6..886bb3b 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/models/User.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/models/User.kt @@ -16,8 +16,9 @@ data class User( fun init(context: Context) { appContext = context.applicationContext val apiKey = PreferencesManager.loadString(appContext, "APIKEY") - if (apiKey.isNotEmpty()) { + if (apiKey.isNotEmpty() && list.isEmpty()) { val user = User(Username = "Default User", ApiKey = apiKey, Information = "") + val list = mutableListOf() list.add(user) saveList(list) } diff --git a/app/src/main/res/drawable/person_add_24px.xml b/app/src/main/res/drawable/person_add_24px.xml new file mode 100644 index 0000000..e21013f --- /dev/null +++ b/app/src/main/res/drawable/person_add_24px.xml @@ -0,0 +1,10 @@ + + + From 8b54f6751d79f9d8e672f8dff1f05cc787d3cc46 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 4 Dec 2025 11:35:07 +0100 Subject: [PATCH 12/12] Version 2.0.1 (21) - added mutliuser feature #17 - added pause event when a healthcheck is paused (indication) #27 - updated libraries - preperation for f-droid #25 - bug fixes --- .../ipv64net/main/activity/MainActivity.kt | 12 +- .../ipv64net/main/startup/views/LoginView.kt | 10 + .../ipv64net/main/views/AccountDetailView.kt | 70 +++---- .../ipv64net/main/views/AccountEditView.kt | 192 ++++++++++++++++++ .../ipv64net/main/views/AccountItemView.kt | 92 +++++++++ .../ipv64net/main/views/AccountView.kt | 8 +- .../java/de/rpicloud/ipv64net/models/Tabs.kt | 2 + 7 files changed, 345 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/de/rpicloud/ipv64net/main/views/AccountEditView.kt create mode 100644 app/src/main/java/de/rpicloud/ipv64net/main/views/AccountItemView.kt diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt b/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt index 25e7fda..0cf29c9 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/activity/MainActivity.kt @@ -41,6 +41,7 @@ import de.rpicloud.ipv64net.helper.PreferencesManager import de.rpicloud.ipv64net.main.startup.views.LoginView import de.rpicloud.ipv64net.main.views.AboutView import de.rpicloud.ipv64net.main.views.AccountDetailView +import de.rpicloud.ipv64net.main.views.AccountEditView import de.rpicloud.ipv64net.main.views.AccountView import de.rpicloud.ipv64net.main.views.DomainDetailView import de.rpicloud.ipv64net.main.views.DomainDnsNewView @@ -73,6 +74,9 @@ class MainActivity : AppCompatActivity() { enableEdgeToEdge() setContent { val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + AppTheme { val isBiometric: Boolean = PreferencesManager.loadBool(applicationContext, "LOCKSCREEN_ENABLED") @@ -80,7 +84,10 @@ class MainActivity : AppCompatActivity() { val enrollLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { println("Activity result $it") } if (!isBiometric || biometricResult == BiometricPromptManager.BiometricResult.AuthenticationSuccess) { - Scaffold(bottomBar = { TabView(Tabs.tabList, navController) }) { mainPadding -> + Scaffold(bottomBar = { + if (currentRoute != Tabs.Companion.getRoute(Tab.login)) + TabView(Tabs.tabList, navController) + }) { mainPadding -> NavHost( navController = navController, startDestination = Tabs.Companion.getRoute(Tab.domains) @@ -124,6 +131,9 @@ class MainActivity : AppCompatActivity() { composable(Tabs.Companion.getRoute(Tab.account_details)) { AccountDetailView(navController, mainPadding = mainPadding) } + composable(Tabs.Companion.getRoute(Tab.account_edit)) { + AccountEditView(navController, mainPadding = mainPadding) + } composable(Tabs.Companion.getRoute(Tab.logs)) { LogView(navController, mainPadding = mainPadding) } diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt index 0c10162..ff8f2b7 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/startup/views/LoginView.kt @@ -96,6 +96,16 @@ fun LoginView(navController: NavHostController, isFromUser: Boolean = false) { title = { Text("Login") }, + navigationIcon = { + if (isFromUser) { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + painter = painterResource(id = R.drawable.arrow_back_24px), + contentDescription = "Close" + ) + } + } + }, actions = { IconButton(onClick = { openCamera() diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt index cc6989a..51c4ef4 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt @@ -5,12 +5,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -18,17 +15,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -37,12 +31,11 @@ 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.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import de.rpicloud.ipv64net.R @@ -109,37 +102,36 @@ fun AccountDetailView(navController: NavHostController, mainPadding: PaddingValu contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - User.list.forEach { user -> - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable(onClick = { - // Action to perform - PreferencesManager.saveString(ctx, "APIKEY", user.ApiKey) - navController.popBackStack() - }).padding(16.dp) - ) { - Column { - Text( - user.Username, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - user.Information.ifEmpty { "No Information" }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(Modifier.weight(1f)) - RadioButton( - selected = User.current!!.ApiKey == user.ApiKey, - onClick = { } + items( + items = User.list, + key = { u -> u.id } // stabile Keys + ) { user -> + AccountItemView( + user, + onClick = { + PreferencesManager.saveString(ctx, "APIKEY", user.ApiKey) + navController.popBackStack() + }, + onLongClick = { + println("LOng Press") + println(it) + navController.currentBackStackEntry?.savedStateHandle?.set( + "SELECTED_USER_APIKEY", + it.ApiKey ) + navController.navigate(Tabs.getRoute(Tab.account_edit)) } - } + ) + } + item { + Text( + "For editing the name and information field, make a long press.", + modifier = Modifier.padding(top = 16.dp) + .fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) } } } diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountEditView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountEditView.kt new file mode 100644 index 0000000..fba8353 --- /dev/null +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountEditView.kt @@ -0,0 +1,192 @@ +package de.rpicloud.ipv64net.main.views + +import android.annotation.SuppressLint +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +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.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import de.rpicloud.ipv64net.R +import de.rpicloud.ipv64net.helper.NetworkService +import de.rpicloud.ipv64net.helper.v64domains +import de.rpicloud.ipv64net.helper.views.ErrorDialog +import de.rpicloud.ipv64net.helper.views.RequestDialogs +import de.rpicloud.ipv64net.helper.views.SpinnerDialog +import de.rpicloud.ipv64net.models.AddDomainResult +import de.rpicloud.ipv64net.models.RequestTyp +import de.rpicloud.ipv64net.models.Tab +import de.rpicloud.ipv64net.models.Tabs +import de.rpicloud.ipv64net.models.User +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@SuppressLint("UnrememberedGetBackStackEntry") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountEditView(navController: NavHostController, mainPadding: PaddingValues) { + + val ctx = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current + val listState = rememberLazyListState() + val scope = rememberCoroutineScope() + + var showLoadingDialog by remember { mutableStateOf(false) } + var showDialog by remember { mutableStateOf(false) } + var username by remember { mutableStateOf("") } + var information by remember { mutableStateOf("") } + var errorDialogTitle by remember { mutableStateOf("") } + var errorDialogText by remember { mutableStateOf("") } + var errorDialogButtonText by remember { mutableIntStateOf(android.R.string.ok) } + + val accountDetailsRoute = Tabs.getRoute(Tab.account_details) + val accountDetailsBackStackEntry = remember(accountDetailsRoute) { + navController.getBackStackEntry(accountDetailsRoute) + } + + val apikey by accountDetailsBackStackEntry.savedStateHandle.getStateFlow("SELECTED_USER_APIKEY", "").collectAsState() + + fun onSave() { + keyboardController?.hide() + if (username.isEmpty()) { + errorDialogTitle = "Empty field" + errorDialogText = "Please fill the Username field!" + errorDialogButtonText = R.string.retry + showDialog = true + return + } + if (information.isEmpty()) { + information = "No Information" + } + + val user = User.list.firstOrNull { it.ApiKey == apikey } + if (user != null) { + user.Username = username + user.Information = information + user.update() + } + navController.popBackStack() + } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon( + painter = painterResource(id = R.drawable.arrow_back_24px), + contentDescription = "Close" + ) + } + }, + title = { + Text("Edit User") + }, modifier = Modifier.statusBarsPadding(), + actions = { + IconButton(onClick = { onSave() }) { + Icon( + painter = painterResource(id = R.drawable.save_24px), + contentDescription = "Save" + ) + } + } + ) + }, + modifier = Modifier + .fillMaxSize() + .padding(mainPadding) + .consumeWindowInsets(mainPadding) // avoids double insets + ) { + Column( + modifier = Modifier + .padding(it) + .fillMaxHeight() + ) { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + OutlinedTextField( + value = username, + singleLine = true, + label = { Text("Username") }, + textStyle = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = 25.dp), + onValueChange = { newText -> username = newText }, + ) + } + item { + OutlinedTextField( + value = information, + singleLine = true, + label = { Text("Information") }, + textStyle = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(top = 25.dp), + onValueChange = { newText -> information = newText }, + ) + } + + } + } + } + + // Wenn kein Scrollen mehr stattfindet, wieder einblenden + LaunchedEffect(Unit) { + val user = User.list.firstOrNull { it.ApiKey == apikey } + if (user != null) { + username = user.Username + information = user.Information + } + } + + if (showDialog) { + ErrorDialog( + onDismissRequest = { showDialog = false }, + onConfirmation = { showDialog = false; }, + dialogTitle = errorDialogTitle, + dialogText = errorDialogText, + dialogConfirmText = errorDialogButtonText + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountItemView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountItemView.kt new file mode 100644 index 0000000..72a7e7b --- /dev/null +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountItemView.kt @@ -0,0 +1,92 @@ +package de.rpicloud.ipv64net.main.views + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.rpicloud.ipv64net.models.User +import de.rpicloud.ipv64net.ui.theme.AppTheme + +@Composable +fun AccountItemView( + user: User, + onClick: (selectedUser: User) -> Unit, + onLongClick: (selectedUser: User) -> Unit +) { + val interactionSource = remember { MutableInteractionSource() } + + Card( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + interactionSource = interactionSource, + indication = LocalIndication.current, // oder `LocalIndication.current` für Ripple + onLongClick = { onLongClick(user) }, + onClick = { onClick(user) } + ) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + user.Username, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + user.Information.ifEmpty { "No Information" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.weight(1f)) + RadioButton( + selected = User.current!!.ApiKey == user.ApiKey, + onClick = { onClick(user) } + ) + } + } +} + +@Preview(showBackground = true, device = "id:pixel_5") +@Composable +fun AccountItemViewPreview() { + AppTheme { + val user = User.empty + user.Information = "No Informations" + user.Username = "Default User" + AccountItemView( + user, + onClick = { + println(it) + }, + onLongClick = { + println(it) + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt index 544a067..1f49052 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountView.kt @@ -35,6 +35,7 @@ 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.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext @@ -185,13 +186,18 @@ fun AccountView(navController: NavHostController, mainPadding: PaddingValues) { verticalArrangement = Arrangement.spacedBy(16.dp) ) { User.current?.let { user -> +// val user = User.empty +// user.Information = "No Informations" +// user.Username = "Default Username" item { Button(onClick = { navController.navigate(Tabs.getRoute(Tab.account_details)) }) { Row( modifier = Modifier - .fillMaxWidth() + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start ) { Icon( painter = painterResource(id = R.drawable.account_circle_24px), diff --git a/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt b/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt index dd068b7..830984c 100644 --- a/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt +++ b/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt @@ -31,6 +31,7 @@ enum class Tab { settings, account, account_details, + account_edit, logs, my_ip, about @@ -90,6 +91,7 @@ sealed class Tabs { Tab.settings -> "settings" Tab.account -> "account" Tab.account_details -> "account_details" + Tab.account_edit -> "account_edit" Tab.domain_details -> "domain_details" Tab.domain_new -> "domain_new" Tab.domain_new_dns -> "domain_new_dns"