diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 5290307..269c6db 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -1,60 +1,41 @@ -name: Android Release +name: Build Android APK on: push: - branches: [ "main" ] - workflow_dispatch: {} - -permissions: - contents: write # notwendig, um Releases/Tags anzulegen + branches: [ main ] + 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/actions/setup-gradle@v5 # ---- 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 debug APK + run: ./gradlew assembleDebug - - name: Build Release AAB - run: ./gradlew :${{ env.APP_MODULE }}:bundleRelease + - name: Build debug AAB + run: ./gradlew bundleDebug # ---- Artefakte umbenennen ---- - name: Rename outputs to the correct version @@ -63,14 +44,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" @@ -83,21 +64,96 @@ 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: ${{ 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 + + - name: Build release AAB + run: ./gradlew bundleRelease + + - 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 + + # ======= 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: ${{ 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 }} \ + -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.renamesign.outputs.version }}-signed + 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.rename.outputs.version }} - name: "ipv64net v${{ steps.rename.outputs.version }}" - draft: false + tag_name: v${{ steps.renamesign.outputs.version }} + name: "ipv64net v${{ steps.renamesign.outputs.version }}" + draft: true prerelease: false files: | - ${{ steps.rename.outputs.apk }} - ${{ steps.rename.outputs.aab }} + ${{ steps.renamesign.outputs.apk }} + ${{ steps.renamesign.outputs.aab }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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..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 @@ -38,7 +38,10 @@ 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.AccountEditView import de.rpicloud.ipv64net.main.views.AccountView import de.rpicloud.ipv64net.main.views.DomainDetailView import de.rpicloud.ipv64net.main.views.DomainDnsNewView @@ -56,6 +59,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,9 +70,13 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + User.init(applicationContext) enableEdgeToEdge() setContent { val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + AppTheme { val isBiometric: Boolean = PreferencesManager.loadBool(applicationContext, "LOCKSCREEN_ENABLED") @@ -76,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) @@ -117,6 +128,12 @@ 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.account_edit)) { + AccountEditView(navController, mainPadding = mainPadding) + } composable(Tabs.Companion.getRoute(Tab.logs)) { LogView(navController, mainPadding = mainPadding) } @@ -126,6 +143,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/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..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 @@ -45,10 +45,11 @@ 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 -fun LoginView(navController: NavHostController) { +fun LoginView(navController: NavHostController, isFromUser: Boolean = false) { val context = LocalContext.current var showDialog by remember { mutableStateOf(false) } @@ -86,7 +87,7 @@ fun LoginView(navController: NavHostController) { } else { readExternalStoragePermissionState.launchPermissionRequest() } - apiKey = PreferencesManager.loadString(context, "APIKEY") + apiKey = "" } Scaffold( @@ -95,6 +96,16 @@ fun LoginView(navController: NavHostController) { 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() @@ -182,12 +193,28 @@ fun LoginView(navController: NavHostController) { Column(modifier = Modifier.padding(bottom = 32.dp)) { Button( onClick = { - 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() + 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() + + 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 @@ -219,7 +246,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/AccountDetailView.kt b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt new file mode 100644 index 0000000..51c4ef4 --- /dev/null +++ b/app/src/main/java/de/rpicloud/ipv64net/main/views/AccountDetailView.kt @@ -0,0 +1,140 @@ +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.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.items +import androidx.compose.foundation.lazy.rememberLazyListState +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.Scaffold +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +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) + ) { + 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 + ) + } + } + } + } + +} \ No newline at end of file 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 dbcea99..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 @@ -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 @@ -31,15 +35,17 @@ 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.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 +53,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 +121,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 +185,46 @@ fun AccountView(navController: NavHostController, mainPadding: PaddingValues) { contentPadding = PaddingValues(16.dp), 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(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + 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 +435,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/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/Tabs.kt b/app/src/main/java/de/rpicloud/ipv64net/models/Tabs.kt index 625268d..830984c 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,8 @@ enum class Tab { integrations, settings, account, + account_details, + account_edit, logs, my_ip, about @@ -88,6 +90,8 @@ sealed class Tabs { Tab.integrations -> "integrations" 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" 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..886bb3b --- /dev/null +++ b/app/src/main/java/de/rpicloud/ipv64net/models/User.kt @@ -0,0 +1,99 @@ +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() && list.isEmpty()) { + val user = User(Username = "Default User", ApiKey = apiKey, Information = "") + val list = mutableListOf() + 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/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 @@ + + + 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" }