From 0632974954187ba2b8ce7f06634d2d75ac4ff414 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sun, 28 Jun 2026 13:05:20 +0800 Subject: [PATCH] Add license revoke action and Russian app title. Support POST /v1/admin/revoke from the license list with confirmation dialog. Co-authored-by: Cursor --- .../ttrpg/licensemanager/data/LicenseApi.kt | 3 + .../ttrpg/licensemanager/data/Models.kt | 8 +++ .../ttrpg/licensemanager/data/Repositories.kt | 4 ++ .../licensemanager/ui/LicenseViewModel.kt | 20 ++++++ .../ui/screens/LicenseListScreen.kt | 64 ++++++++++++++++++- app/src/main/res/values/strings.xml | 2 +- scripts/setup-android-sdk.ps1 | 33 ++++++++++ 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 scripts/setup-android-sdk.ps1 diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt index 257157d..ac720d3 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/LicenseApi.kt @@ -10,4 +10,7 @@ interface LicenseApi { @POST("v1/admin/product-keys") suspend fun createProductKey(@Body body: CreateProductKeyRequest): ProductKeyResponse + + @POST("v1/admin/revoke") + suspend fun revokeLicense(@Body body: RevokeLicenseRequest): RevokeLicenseResponse } diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt index 94c8229..0cc1637 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Models.kt @@ -31,6 +31,14 @@ data class ProductKeyResponse( val expiresAtSec: Long, ) +data class RevokeLicenseRequest( + val sub: String, +) + +data class RevokeLicenseResponse( + val ok: Boolean, +) + data class ApiErrorBody( @SerializedName("error") val error: String?, ) diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt index 8807c74..430f4db 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/data/Repositories.kt @@ -60,6 +60,10 @@ class LicenseRepository( createApi(settings).createProductKey(request) } + suspend fun revokeLicense(settings: AppSettings, sub: String): Result = runCatching { + createApi(settings).revokeLicense(RevokeLicenseRequest(sub)) + } + private fun createApi(settings: AppSettings): LicenseApi { require(settings.adminToken.isNotBlank()) { "Укажите admin token в настройках" } val baseUrl = settings.serverUrl diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt index 3a5de32..c3efff1 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/LicenseViewModel.kt @@ -23,6 +23,7 @@ import java.time.ZoneOffset data class LicenseListUiState( val licenses: List = emptyList(), val isLoading: Boolean = false, + val revokingSub: String? = null, val error: String? = null, ) @@ -84,6 +85,25 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application) } } + fun revokeLicense(sub: String) { + viewModelScope.launch { + _listState.update { it.copy(revokingSub = sub, error = null) } + licenseRepository.revokeLicense(settings.value, sub) + .onSuccess { + loadLicenses() + _listState.update { it.copy(revokingSub = null) } + } + .onFailure { e -> + _listState.update { + it.copy( + revokingSub = null, + error = e.message ?: "Не удалось отозвать лицензию", + ) + } + } + } + } + fun updatePid(value: String) { _generateState.update { it.copy(pid = value, error = null) } } diff --git a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt index a9a9871..f01a22d 100644 --- a/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt +++ b/app/src/main/java/ru/mailib/ttrpg/licensemanager/ui/screens/LicenseListScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -13,15 +14,21 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily @@ -36,6 +43,7 @@ import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec @Composable fun LicenseListScreen(viewModel: LicenseViewModel) { val state by viewModel.listState.collectAsStateWithLifecycle() + var confirmRevoke by remember { mutableStateOf(null) } val refreshState = rememberPullRefreshState( refreshing = state.isLoading, onRefresh = viewModel::loadLicenses, @@ -45,6 +53,31 @@ fun LicenseListScreen(viewModel: LicenseViewModel) { viewModel.loadLicenses() } + confirmRevoke?.let { license -> + AlertDialog( + onDismissRequest = { confirmRevoke = null }, + title = { Text("Отозвать лицензию?") }, + text = { + Text("Ключ ${license.key} перестанет работать у всех пользователей.") + }, + confirmButton = { + TextButton( + onClick = { + viewModel.revokeLicense(license.sub) + confirmRevoke = null + }, + ) { + Text("Отозвать") + } + }, + dismissButton = { + TextButton(onClick = { confirmRevoke = null }) { + Text("Отмена") + } + }, + ) + } + Box( modifier = Modifier .fillMaxSize() @@ -75,8 +108,17 @@ fun LicenseListScreen(viewModel: LicenseViewModel) { contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (state.error != null) { + item(key = "error") { + ErrorMessage(message = state.error ?: "") + } + } items(state.licenses, key = { it.sub }) { license -> - LicenseCard(license) + LicenseCard( + license = license, + isRevoking = state.revokingSub == license.sub, + onRevoke = { confirmRevoke = license }, + ) } } } @@ -91,7 +133,11 @@ fun LicenseListScreen(viewModel: LicenseViewModel) { } @Composable -private fun LicenseCard(license: LicenseEntry) { +private fun LicenseCard( + license: LicenseEntry, + isRevoking: Boolean, + onRevoke: () -> Unit, +) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -117,6 +163,20 @@ private fun LicenseCard(license: LicenseEntry) { if (license.activatedDevices.isNotEmpty()) { DetailRow("deviceId", license.activatedDevices.joinToString(", ")) } + if (!license.revoked) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + OutlinedButton( + onClick = onRevoke, + enabled = !isRevoking, + ) { + if (isRevoking) { + CircularProgressIndicator(strokeWidth = 2.dp) + } else { + Text("Отозвать") + } + } + } + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da4afd5..58bb1b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - TTRPG License Manager + Менеджер лицензий TTRPG Лицензии Новый ключ Настройки diff --git a/scripts/setup-android-sdk.ps1 b/scripts/setup-android-sdk.ps1 new file mode 100644 index 0000000..f4d3f46 --- /dev/null +++ b/scripts/setup-android-sdk.ps1 @@ -0,0 +1,33 @@ +# Install Android SDK components for headless builds. +$ErrorActionPreference = "Stop" + +$sdkRoot = "$env:LOCALAPPDATA\Android\Sdk" +$cmdline = Join-Path $sdkRoot "cmdline-tools\latest\bin" +$sdkmanager = Join-Path $cmdline "sdkmanager.bat" + +if (-not (Test-Path $sdkmanager)) { + Write-Host "Downloading Android command-line tools..." + New-Item -ItemType Directory -Force -Path "$sdkRoot\cmdline-tools" | Out-Null + $zip = Join-Path $env:TEMP "cmdline-tools.zip" + Invoke-WebRequest -Uri "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip" -OutFile $zip + Expand-Archive -Path $zip -DestinationPath "$sdkRoot\cmdline-tools\tmp" -Force + if (Test-Path "$sdkRoot\cmdline-tools\latest") { + Remove-Item "$sdkRoot\cmdline-tools\latest" -Recurse -Force + } + Move-Item "$sdkRoot\cmdline-tools\tmp\cmdline-tools" "$sdkRoot\cmdline-tools\latest" + Remove-Item "$sdkRoot\cmdline-tools\tmp" -Recurse -Force +} + +Write-Host "Installing SDK packages..." +$packages = @( + "platform-tools", + "platforms;android-35", + "build-tools;35.0.0" +) + +foreach ($pkg in $packages) { + cmd /c "echo y| `"$sdkmanager`" `"$pkg`"" +} + +Write-Host "ANDROID_HOME=$sdkRoot" +Write-Host "Done."