diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8aaad25..bd20374 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -6,6 +6,7 @@ on: - main - "feature/**" - "fix/**" + - "parity/**" tags: - "v*" pull_request: @@ -16,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: android-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build: name: Build APK @@ -29,16 +34,16 @@ jobs: steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "21" - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 with: packages: platform-tools @@ -46,7 +51,7 @@ jobs: run: sdkmanager "platforms;android-35" "build-tools;35.0.0" - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5 - name: Make Gradle wrapper executable run: chmod +x ./gradlew @@ -58,11 +63,14 @@ jobs: - name: Run unit tests run: ./gradlew --no-daemon :app:testDebugUnitTest + - name: Run Android lint + run: ./gradlew --no-daemon :app:lintDebug + - name: Build debug APK run: ./gradlew --no-daemon :app:assembleDebug - name: Upload debug APK - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Arcane-debug-apk path: app/build/outputs/apk/debug/*.apk @@ -101,7 +109,7 @@ jobs: - name: Upload signed release APK if: startsWith(github.ref, 'refs/tags/v') && env.HAS_RELEASE_SIGNING == 'true' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Arcane-release-signed-apk path: Arcane-${{ github.ref_name }}.apk @@ -111,6 +119,53 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') && env.HAS_RELEASE_SIGNING != 'true' run: echo "Release signing secrets are not configured; debug APK artifact was uploaded." + instrumented: + name: API 30 instrumented tests + if: github.event_name != 'push' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up JDK + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 + with: + packages: platform-tools + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-35" "build-tools;35.0.0" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Enable KVM access + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run deterministic Compose and route tests + uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 + with: + api-level: 30 + arch: x86_64 + profile: pixel_2 + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-metrics -camera-back none + script: ./gradlew --no-daemon :app:connectedDebugAndroidTest + publish-release: name: Publish signed release runs-on: ubuntu-latest @@ -121,13 +176,13 @@ jobs: steps: - name: Download signed release APK - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Arcane-release-signed-apk path: . - name: Attach signed APK to GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: files: Arcane-${{ github.ref_name }}.apk fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index 003908d..4c77725 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ captures/ *.hprof lint-results* lint-baseline.xml +!app/lint-baseline.xml # ---------------------------- # Test & coverage artefacts diff --git a/README.md b/README.md index 6c703ee..f7e51c9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ > [!IMPORTANT] -> This project is still under development and is not intended to be used on devices yet. +> Arcane Mobile is a public alpha. Use it with disposable or well-backed-up environments while the +> beta release matrix is being completed; it can perform destructive Docker operations.
@@ -22,11 +23,15 @@ Arcane Mobile is the official Android companion for [Arcane](https://github.com/ For setup instructions, configuration details, and development guides, visit the **[official documentation site](https://getarcane.app/docs)**. For continuation context from the currently running Android app, see [`docs/current-app-state.md`](docs/current-app-state.md). +Release support, promotion gates, and known limitations are documented in +[`docs/release-readiness.md`](docs/release-readiness.md). Testing/security gates and the accessibility/ +localization foundation are documented in [`docs/testing-and-quality-gates.md`](docs/testing-and-quality-gates.md) +and [`docs/accessibility-and-localization.md`](docs/accessibility-and-localization.md). ## Requirements - Android 7.0 (API 24) or later -- An Arcane server reachable over HTTPS +- Arcane 2.10.2 or later, reachable over HTTPS (newer features remain capability-gated) ## Building @@ -49,8 +54,13 @@ Run the same checks used by CI with: ```sh ./gradlew :app:testDebugUnitTest :app:assembleDebug +./gradlew :app:lintDebug ``` +The API 30 instrumentation lane runs with `./gradlew :app:connectedDebugAndroidTest`. Destructive or +streaming live-server checks are a separately reported disposable-server lane; see the testing guide +before supplying test credentials. + Release APK signing is optional for local builds. To sign a release APK, provide: ```sh @@ -72,7 +82,9 @@ before cutting a signed release tag: - `ARCANE_RELEASE_KEY_ALIAS` - `ARCANE_RELEASE_KEY_PASSWORD` -Fastlane/F-Droid metadata lives under `fastlane/metadata/android/en-US/`. +Fastlane metadata lives under `fastlane/metadata/android/en-US/`. F-Droid preparation and its current +dependency-eligibility blockers are documented in [`docs/fdroid-release-preparation.md`](docs/fdroid-release-preparation.md); +the project has not been submitted to F-Droid. ### Android Studio run configuration diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3fff182..07ab97b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,6 +44,9 @@ android { } buildTypes { + debug { + isPseudoLocalesEnabled = true + } release { if (hasReleaseSigning) { signingConfig = signingConfigs.getByName("release") @@ -65,6 +68,13 @@ android { buildConfig = true compose = true } + + lint { + baseline = file("lint-baseline.xml") + abortOnError = true + warningsAsErrors = true + checkReleaseBuilds = true + } } kotlin { @@ -100,7 +110,7 @@ dependencies { // OkHttp HTTP engine for Ktor: robust TLS/HTTP-2 on Android (the pure-Kotlin CIO engine // fails the TLS handshake against some Cloudflare-fronted hosts, e.g. demo.getarcane.app). - implementation("io.ktor:ktor-client-okhttp:3.0.3") + implementation(libs.ktor.client.okhttp) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 0000000..faea60a --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/androidTest/java/app/getarcane/android/AuthenticatedRouteInstrumentedTest.kt b/app/src/androidTest/java/app/getarcane/android/AuthenticatedRouteInstrumentedTest.kt new file mode 100644 index 0000000..048188c --- /dev/null +++ b/app/src/androidTest/java/app/getarcane/android/AuthenticatedRouteInstrumentedTest.kt @@ -0,0 +1,27 @@ +package app.getarcane.android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.getarcane.android.nav.AuthenticatedRoute +import app.getarcane.android.nav.AuthenticatedRouteCodec +import app.getarcane.android.nav.RouteDestination +import app.getarcane.android.nav.RouteParseResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AuthenticatedRouteInstrumentedTest { + @Test + fun serverBoundResourceRouteRoundTripsAndMalformedRouteFailsClosed() { + val route = AuthenticatedRoute( + serverBindingHash = "a".repeat(64), + destination = RouteDestination.CONTAINER, + environmentId = "environment-1", + resourceId = "container-1", + ) + + assertEquals(RouteParseResult.Valid(route), AuthenticatedRouteCodec.parse(AuthenticatedRouteCodec.encode(route))) + assertTrue(AuthenticatedRouteCodec.parse("arcane-mobile://route/v1/current/container/-/-") is RouteParseResult.Invalid) + } +} diff --git a/app/src/androidTest/java/app/getarcane/android/ExampleInstrumentedTest.kt b/app/src/androidTest/java/app/getarcane/android/ExampleInstrumentedTest.kt deleted file mode 100644 index fe11491..0000000 --- a/app/src/androidTest/java/app/getarcane/android/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.getarcane.android - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("app.getarcane.android", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/app/getarcane/android/MainActivitySystemBarTest.kt b/app/src/androidTest/java/app/getarcane/android/MainActivitySystemBarTest.kt new file mode 100644 index 0000000..6e5c38a --- /dev/null +++ b/app/src/androidTest/java/app/getarcane/android/MainActivitySystemBarTest.kt @@ -0,0 +1,33 @@ +package app.getarcane.android + +import android.Manifest +import android.os.Build +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertFalse +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivitySystemBarTest { + @Test + fun navigationBarContrastScrimIsDisabledOnSupportedApis() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.grantRuntimePermission( + instrumentation.targetContext.packageName, + Manifest.permission.POST_NOTIFICATIONS, + ) + } + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertFalse(activity.window.isNavigationBarContrastEnforced) + } + } + } +} diff --git a/app/src/androidTest/java/app/getarcane/android/PinnedItemsStoreInstrumentedTest.kt b/app/src/androidTest/java/app/getarcane/android/PinnedItemsStoreInstrumentedTest.kt new file mode 100644 index 0000000..f7a2c8c --- /dev/null +++ b/app/src/androidTest/java/app/getarcane/android/PinnedItemsStoreInstrumentedTest.kt @@ -0,0 +1,34 @@ +package app.getarcane.android + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.getarcane.android.core.PinnedItemsStore +import app.getarcane.sdk.EnvironmentId +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PinnedItemsStoreInstrumentedTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Before + @After + fun clearPins() { + context.getSharedPreferences("arcane_pinned", Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test + fun pinsRemainEnvironmentScopedAfterStoreRecreation() { + val first = PinnedItemsStore(context) + first.togglePin("shared-id", PinnedItemsStore.Kind.CONTAINER, EnvironmentId("0")) + first.togglePin("remote-only", PinnedItemsStore.Kind.CONTAINER, EnvironmentId("remote")) + + val restored = PinnedItemsStore(context) + assertEquals(setOf("shared-id"), restored.pinnedIds(PinnedItemsStore.Kind.CONTAINER, EnvironmentId("0"))) + assertEquals(setOf("remote-only"), restored.pinnedIds(PinnedItemsStore.Kind.CONTAINER, EnvironmentId("remote"))) + } +} diff --git a/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt new file mode 100644 index 0000000..b90f6da --- /dev/null +++ b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt @@ -0,0 +1,155 @@ +package app.getarcane.android + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.pressBack +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import app.getarcane.android.core.AuthStatus +import app.getarcane.android.core.OperationKind +import app.getarcane.android.core.OperationRecord +import app.getarcane.android.core.OperationRecoveryMode +import app.getarcane.android.core.OperationState +import app.getarcane.android.core.OperationTargetType +import app.getarcane.android.ui.AuthRouteContent +import app.getarcane.android.ui.components.DestructiveConfirmationDialog +import app.getarcane.android.ui.operations.OperationDetail +import app.getarcane.android.ui.screens.environments.EnvironmentRow +import app.getarcane.android.ui.theme.ArcaneTheme +import app.getarcane.sdk.models.environment.Environment +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ReleaseReadinessUiTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun authenticationRoutingDoesNotShowLoginWhileRestoring() { + composeRule.setContent { + var status by mutableStateOf(AuthStatus.AUTHENTICATING) + ArcaneTheme { + AuthRouteContent( + authStatus = status, + authenticatingContent = { + TextButton(onClick = { status = AuthStatus.AUTHENTICATED }) { + Text("Complete restoration") + } + }, + loginContent = { Text("Login", Modifier.testTag("route")) }, + authenticatedContent = { Text("Dashboard", Modifier.testTag("route")) }, + ) + } + } + + composeRule.onNodeWithText("Login").assertDoesNotExist() + composeRule.onNodeWithText("Complete restoration").performClick() + composeRule.onNodeWithTag("route").assertTextEquals("Dashboard") + + } + + @Test + fun destructiveConfirmationRequiresExplicitActionAndBackOnlyDismisses() { + var confirmations = 0 + var dismissals = 0 + composeRule.setContent { + ArcaneTheme { + DestructiveConfirmationDialog( + title = "Delete test container?", + message = "This action cannot be undone.", + confirmLabel = "Delete", + onConfirm = { confirmations++ }, + onDismiss = { dismissals++ }, + ) + } + } + + composeRule.onNodeWithText("Delete test container?").assertIsDisplayed() + onView(isRoot()).inRoot(isDialog()).perform(pressBack()) + composeRule.runOnIdle { + assertEquals(0, confirmations) + assertEquals(1, dismissals) + } + } + + @Test + fun environmentSelectionIsExposedAndTargetsTheChosenEnvironment() { + var selectedId by mutableStateOf("local") + val remote = Environment( + id = "remote", + name = "Remote lab", + apiUrl = "https://example.invalid", + status = "online", + ) + composeRule.setContent { + ArcaneTheme { + EnvironmentRow( + env = remote, + isActive = selectedId == remote.id, + onClick = { selectedId = remote.id }, + onSetActive = { selectedId = remote.id }, + ) + } + } + + composeRule.onNodeWithText("Remote lab").assertHasClickAction().performClick() + composeRule.onNodeWithText("Remote lab").assertIsSelected() + composeRule.runOnIdle { assertEquals("remote", selectedId) } + } + + @Test + fun representativeOperationShowsTruthfulStateAndAction() { + var cancels = 0 + val record = OperationRecord( + operationId = "11111111-1111-4111-8111-111111111111", + kind = OperationKind.PROJECT_DEPLOY, + state = OperationState.RUNNING, + createdAtEpochMs = 1L, + updatedAtEpochMs = 2L, + serverBindingHash = "server", + accountBindingHash = "account", + credentialOriginHash = "credential", + environmentId = "remote", + targetType = OperationTargetType.PROJECT, + opaqueTargetId = "project-id", + duplicateKeyDigest = "duplicate", + activityBatchId = "11111111-1111-4111-8111-111111111111", + recoveryMode = OperationRecoveryMode.ACTIVITY, + serverActivityId = "activity-id", + progressPercent = 40, + targetName = "demo project", + ) + composeRule.setContent { + ArcaneTheme { + OperationDetail( + record = record, + onBack = {}, + onCancel = { cancels++ }, + onRetry = {}, + onDismiss = {}, + onOpenActivity = {}, + canCancel = true, + ) + } + } + + composeRule.onNodeWithText("Running").assertIsDisplayed() + composeRule.onNodeWithText("Cancel").performClick() + composeRule.runOnIdle { assertEquals(1, cancels) } + } +} diff --git a/app/src/androidTest/java/app/getarcane/android/nav/AdaptiveNavigationShellTest.kt b/app/src/androidTest/java/app/getarcane/android/nav/AdaptiveNavigationShellTest.kt index 32d3abd..c4c4fdd 100644 --- a/app/src/androidTest/java/app/getarcane/android/nav/AdaptiveNavigationShellTest.kt +++ b/app/src/androidTest/java/app/getarcane/android/nav/AdaptiveNavigationShellTest.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.click import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.longClick -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTouchInput import app.getarcane.android.ui.theme.ArcaneTheme import org.junit.Rule @@ -47,12 +47,12 @@ class AdaptiveNavigationShellTest { } composeRule - .onNodeWithContentDescription("Volumes", useUnmergedTree = true) + .onNodeWithText("Volumes") .performTouchInput { click() } composeRule.onNodeWithTag("result").assertTextEquals("volumes") composeRule - .onNodeWithContentDescription("Dashboard", useUnmergedTree = true) + .onNodeWithText("Dashboard") .performTouchInput { click() } composeRule.onNodeWithTag("result").assertTextEquals("dashboard") } @@ -85,7 +85,7 @@ class AdaptiveNavigationShellTest { } composeRule - .onNodeWithContentDescription("Dashboard", useUnmergedTree = true) + .onNodeWithText("Dashboard") .performTouchInput { longClick() } composeRule.onNodeWithTag("result").assertTextEquals("volumes:dashboard") } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1793a32..5f2808b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,7 +36,6 @@ = Build.VERSION_CODES.Q) { + // Preserve the app surface behind gesture and three-button navigation in both themes. + window.isNavigationBarContrastEnforced = false + } setContent { val context = LocalContext.current val manager = remember { arcaneManager } @@ -60,7 +66,7 @@ class MainActivity : ComponentActivity() { val themeMode by appearancePreferences.themeMode.collectAsState() val darkTheme = themeMode.resolvesToDark(isSystemInDarkTheme()) val accent = accentHex - ?.let { hex -> runCatching { Color(android.graphics.Color.parseColor(if (hex.startsWith("#")) hex else "#$hex")) }.getOrNull() } + ?.let { hex -> runCatching { Color((if (hex.startsWith("#")) hex else "#$hex").toColorInt()) }.getOrNull() } ?: ArcaneBlue SideEffect { WindowCompat.getInsetsController(window, window.decorView).apply { diff --git a/app/src/main/kotlin/app/getarcane/android/core/FleetUpdateSummary.kt b/app/src/main/kotlin/app/getarcane/android/core/FleetUpdateSummary.kt new file mode 100644 index 0000000..96b40c8 --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/core/FleetUpdateSummary.kt @@ -0,0 +1,34 @@ +package app.getarcane.android.core + +import app.getarcane.sdk.models.system.EnvironmentUpdateJob +import app.getarcane.sdk.models.system.EnvironmentUpdateResultStatus + +internal data class FleetUpdateSummary( + val updated: Int, + val failed: Int, + val offline: Int, + val upToDate: Int, + val pending: Int, + val targetVersion: String?, +) + +internal fun fleetUpdateSummary(job: EnvironmentUpdateJob): FleetUpdateSummary { + val results = job.results.orEmpty() + val updated = results.count { + it.status == EnvironmentUpdateResultStatus.UPDATED || + it.status == EnvironmentUpdateResultStatus.TRIGGERED + } + val failed = results.count { it.status == EnvironmentUpdateResultStatus.FAILED } + val offline = results.count { it.status == EnvironmentUpdateResultStatus.SKIPPED_OFFLINE } + val upToDate = results.count { it.status == EnvironmentUpdateResultStatus.SKIPPED_UP_TO_DATE } + return FleetUpdateSummary( + updated = updated, + failed = failed, + offline = offline, + upToDate = upToDate, + pending = results.size - updated - failed - offline - upToDate, + targetVersion = job.managerTargetVersion?.takeIf { + it.isNotBlank() && ':' !in it && it.length <= 20 + }, + ) +} diff --git a/app/src/main/kotlin/app/getarcane/android/core/Format.kt b/app/src/main/kotlin/app/getarcane/android/core/Format.kt index 9d94c0d..b4ae7a8 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/Format.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/Format.kt @@ -12,7 +12,7 @@ fun formatBytes(bytes: Long): String { value /= 1024 i++ } - return if (i == 0) "$bytes B" else String.format("%.1f %s", value, units[i]) + return if (i == 0) "$bytes B" else String.format(java.util.Locale.getDefault(), "%.1f %s", value, units[i]) } /** Display name for an image: first real repo:tag, else short id. Mirrors iOS `ImageSummary.displayName`. */ diff --git a/app/src/main/kotlin/app/getarcane/android/core/OperationNotificationProjector.kt b/app/src/main/kotlin/app/getarcane/android/core/OperationNotificationProjector.kt index 6e0f9f7..4ad0d2c 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/OperationNotificationProjector.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/OperationNotificationProjector.kt @@ -7,17 +7,18 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager -import android.net.Uri import android.os.Build +import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat +import androidx.core.net.toUri import app.getarcane.android.MainActivity +import app.getarcane.android.OperationActionReceiver +import app.getarcane.android.R import app.getarcane.android.nav.AuthenticatedRoute import app.getarcane.android.nav.AuthenticatedRouteCodec import app.getarcane.android.nav.RouteDestination -import app.getarcane.android.OperationActionReceiver -import app.getarcane.android.R import app.getarcane.sdk.models.user.hasPermission internal class OperationNotificationProjector( @@ -65,17 +66,26 @@ internal class OperationNotificationProjector( (postedIds - nextIds).forEach(notificationManager::cancel) if (visible.size > 1) { val active = visible.count(OperationRecord::isActive) - val summaryText = if (active > 0) "$active active operations" else "${visible.size} recent operations" + val summaryText = if (active > 0) { + context.resources.getQuantityString(R.plurals.operation_notification_active_summary, active, active) + } else { + context.resources.getQuantityString( + R.plurals.operation_notification_recent_summary, + visible.size, + visible.size, + ) + } + val groupTitle = context.getString(R.string.operation_notification_group_title) val summary = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_operation_notification) - .setContentTitle("Arcane operations") + .setContentTitle(groupTitle) .setContentText(summaryText) .setContentIntent(centerIntent()) .setGroup(GROUP_KEY) .setGroupSummary(true) .setOnlyAlertOnce(true) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - .setPublicVersion(publicNotification("Arcane operations")) + .setPublicVersion(publicNotification(groupTitle)) .build() notificationManager.notify(SUMMARY_ID, summary) } else { @@ -89,14 +99,13 @@ internal class OperationNotificationProjector( } private fun notification(operation: OperationRecord, notificationId: Int): android.app.Notification { - val stateText = operation.notificationStateText() - val text = buildString { - append(stateText) - operation.environmentName.takeIf(String::isNotBlank)?.let { append(" · ").append(it.take(80)) } - } + val stateText = operation.notificationStateText(context) + val text = operation.environmentName.takeIf(String::isNotBlank)?.let { + context.getString(R.string.operation_notification_detail, stateText, it.take(80)) + } ?: stateText val builder = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_operation_notification) - .setContentTitle(operation.kind.notificationTitle()) + .setContentTitle(context.getString(operation.kind.notificationTitleRes())) .setContentText(text) .setContentIntent(operationIntent(operation.operationId, notificationId)) .setGroup(GROUP_KEY) @@ -104,7 +113,17 @@ internal class OperationNotificationProjector( .setAutoCancel(operation.isTerminalLike) .setOngoing(false) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) - .setPublicVersion(publicNotification(if (operation.isActive) "Arcane operation in progress" else "Arcane operation finished")) + .setPublicVersion( + publicNotification( + context.getString( + if (operation.isActive) { + R.string.operation_notification_public_active + } else { + R.string.operation_notification_public_finished + }, + ), + ), + ) operation.progressPercent?.takeIf { operation.isActive }?.let { builder.setProgress(100, it, false) } ?: if (operation.isActive) builder.setProgress(0, 0, true) else Unit if (operation.isActive && operation.serverActivityId != null && @@ -128,15 +147,13 @@ internal class OperationNotificationProjector( private fun operationIntent(operationId: String, requestCode: Int): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { - data = Uri.parse( - AuthenticatedRouteCodec.encode( - AuthenticatedRoute( - serverBindingHash = sha256(manager.serverSessionIdentity), - destination = RouteDestination.OPERATION, - resourceId = operationId, - ), + data = AuthenticatedRouteCodec.encode( + AuthenticatedRoute( + serverBindingHash = sha256(manager.serverSessionIdentity), + destination = RouteDestination.OPERATION, + resourceId = operationId, ), - ) + ).toUri() flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP } return PendingIntent.getActivity( @@ -149,14 +166,12 @@ internal class OperationNotificationProjector( private fun centerIntent(): PendingIntent { val intent = Intent(context, MainActivity::class.java).apply { - data = Uri.parse( - AuthenticatedRouteCodec.encode( - AuthenticatedRoute( - serverBindingHash = null, - destination = RouteDestination.OPERATIONS, - ), + data = AuthenticatedRouteCodec.encode( + AuthenticatedRoute( + serverBindingHash = null, + destination = RouteDestination.OPERATIONS, ), - ) + ).toUri() flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP } return PendingIntent.getActivity( @@ -170,7 +185,7 @@ internal class OperationNotificationProjector( private fun cancelIntent(operationId: String, requestCode: Int): PendingIntent { val intent = Intent(context, OperationActionReceiver::class.java).apply { action = OperationActionReceiver.ACTION_CANCEL - data = Uri.parse("arcane-mobile://operations/$operationId/cancel") + data = "arcane-mobile://operations/$operationId/cancel".toUri() } return PendingIntent.getBroadcast( context, @@ -197,34 +212,37 @@ internal class OperationNotificationProjector( } } -internal fun OperationKind.notificationTitle(): String = when (this) { - OperationKind.PROJECT_DEPLOY -> "Project deploy" - OperationKind.PROJECT_REDEPLOY -> "Project redeploy" - OperationKind.PROJECT_PULL -> "Project image pull" - OperationKind.PROJECT_BUILD -> "Project build" - OperationKind.IMAGE_PULL -> "Image pull" - OperationKind.CONTAINER_REDEPLOY -> "Container redeploy" - OperationKind.UPDATER_RUN -> "Updater run" - OperationKind.FLEET_UPDATE -> "Fleet update" - OperationKind.UNKNOWN -> "Arcane operation" +@StringRes +internal fun OperationKind.notificationTitleRes(): Int = when (this) { + OperationKind.PROJECT_DEPLOY -> R.string.operation_notification_project_deploy + OperationKind.PROJECT_REDEPLOY -> R.string.operation_notification_project_redeploy + OperationKind.PROJECT_PULL -> R.string.operation_notification_project_pull + OperationKind.PROJECT_BUILD -> R.string.operation_notification_project_build + OperationKind.IMAGE_PULL -> R.string.operation_notification_image_pull + OperationKind.CONTAINER_REDEPLOY -> R.string.operation_notification_container_redeploy + OperationKind.UPDATER_RUN -> R.string.operation_notification_updater + OperationKind.FLEET_UPDATE -> R.string.operation_notification_fleet_update + OperationKind.UNKNOWN -> R.string.operation_notification_unknown } -internal fun OperationRecord.notificationStateText(): String = when (state) { - OperationState.QUEUED -> "Queued" - OperationState.STARTING -> "Starting" - OperationState.RUNNING -> progressPercent?.let { "$it% complete" } ?: "Running" - OperationState.RECONNECTING -> "Reconnecting" - OperationState.CANCEL_REQUESTED -> "Cancellation requested" - OperationState.SUCCESS -> "Completed" +internal fun OperationRecord.notificationStateText(context: Context): String = when (state) { + OperationState.QUEUED -> context.getString(R.string.operation_state_queued) + OperationState.STARTING -> context.getString(R.string.operation_state_starting) + OperationState.RUNNING -> progressPercent?.let { + context.getString(R.string.operation_notification_progress, it) + } ?: context.getString(R.string.operation_state_running) + OperationState.RECONNECTING -> context.getString(R.string.operation_state_reconnecting) + OperationState.CANCEL_REQUESTED -> context.getString(R.string.operation_state_cancel_requested) + OperationState.SUCCESS -> context.getString(R.string.operation_state_completed) OperationState.FAILURE -> if (presentationCode == OperationPresentationCode.COMPLETED_WITH_ISSUES) { - "Completed with issues" + context.getString(R.string.operation_state_completed_with_issues) } else { - "Failed" + context.getString(R.string.operation_state_failed) } - OperationState.CANCELLED -> "Cancelled" - OperationState.INTERRUPTED -> "Interrupted" - OperationState.UNKNOWN -> "Outcome unknown" - OperationState.CLEARED -> "Finished" + OperationState.CANCELLED -> context.getString(R.string.operation_state_cancelled) + OperationState.INTERRUPTED -> context.getString(R.string.operation_state_interrupted) + OperationState.UNKNOWN -> context.getString(R.string.operation_state_unknown) + OperationState.CLEARED -> context.getString(R.string.operation_state_finished) } internal const val OPERATION_NOTIFICATION_SUMMARY_ID = 2_041_145_001 diff --git a/app/src/main/kotlin/app/getarcane/android/core/OperationStore.kt b/app/src/main/kotlin/app/getarcane/android/core/OperationStore.kt index e65058b..360e4ae 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/OperationStore.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/OperationStore.kt @@ -559,10 +559,8 @@ class OperationStore internal constructor( while (true) { appendFleetResults(operationId, job) if (job.isTerminal) { - val failed = job.results.orEmpty().count { - it.status == EnvironmentUpdateResultStatus.FAILED || - it.status == EnvironmentUpdateResultStatus.SKIPPED_OFFLINE - } + val summary = fleetUpdateSummary(job) + val failed = summary.failed + summary.offline if (job.status == EnvironmentUpdateJobStatus.COMPLETED && failed == 0) { finish(operationId, OperationState.SUCCESS, OperationPresentationCode.COMPLETE) } else { diff --git a/app/src/main/kotlin/app/getarcane/android/core/PinnedItemsStore.kt b/app/src/main/kotlin/app/getarcane/android/core/PinnedItemsStore.kt index 15ef174..d6a099d 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/PinnedItemsStore.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/PinnedItemsStore.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf +import androidx.core.content.edit import app.getarcane.sdk.EnvironmentId /** Per-(kind, environment) pinned resource IDs. Port of iOS `PinnedItemsStore` (UserDefaults-backed). */ @@ -29,7 +30,7 @@ class PinnedItemsStore(context: Context) { val key = key(kind, envId) val current = (prefs.getStringSet(key, emptySet()) ?: emptySet()).toMutableSet() if (!current.add(id)) current.remove(id) - prefs.edit().putStringSet(key, current).apply() + prefs.edit { putStringSet(key, current) } version++ } @@ -37,7 +38,7 @@ class PinnedItemsStore(context: Context) { val key = key(kind, envId) val current = (prefs.getStringSet(key, emptySet()) ?: emptySet()).toMutableSet() if (current.remove(id)) { - prefs.edit().putStringSet(key, current).apply() + prefs.edit { putStringSet(key, current) } version++ } } diff --git a/app/src/main/kotlin/app/getarcane/android/core/ProjectDeployPreferences.kt b/app/src/main/kotlin/app/getarcane/android/core/ProjectDeployPreferences.kt index 6074b20..d94c075 100644 --- a/app/src/main/kotlin/app/getarcane/android/core/ProjectDeployPreferences.kt +++ b/app/src/main/kotlin/app/getarcane/android/core/ProjectDeployPreferences.kt @@ -1,6 +1,7 @@ package app.getarcane.android.core import android.content.Context +import androidx.core.content.edit import android.content.SharedPreferences import app.getarcane.sdk.models.project.DeployOptions import app.getarcane.sdk.models.project.DeployPullPolicy @@ -92,14 +93,14 @@ private class SharedPreferencesProjectDeployStorage( if (preferences.contains(key)) preferences.getBoolean(key, false) else null override fun put(values: Map) { - preferences.edit().apply { + preferences.edit { values.forEach { (key, value) -> when (value) { is String -> putString(key, value) is Boolean -> putBoolean(key, value) } } - }.apply() + } } } diff --git a/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigationShell.kt b/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigationShell.kt index ce7f5c4..979ca98 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigationShell.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/AdaptiveNavigationShell.kt @@ -45,8 +45,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.getarcane.android.R import kotlinx.coroutines.withTimeoutOrNull private const val SETTINGS_ID = MainTabSelection.SETTINGS_ID @@ -79,8 +83,8 @@ internal fun AdaptiveNavigationShell( ) } CompactNavigationItem( - icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") }, - label = "Settings", + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, + label = stringResource(R.string.nav_settings), selected = selectedTabId == SETTINGS_ID, onClick = { onSelect(SETTINGS_ID) }, ) @@ -95,21 +99,21 @@ internal fun AdaptiveNavigationShell( NavigationRailItem( selected = selectedTabId == tab.id, onClick = { onSelect(tab.id) }, - icon = { Icon(tab.icon, contentDescription = tab.title) }, - label = { Text(tab.tabBarTitle, maxLines = 1) }, + icon = { Icon(tab.icon, contentDescription = null) }, + label = { Text(stringResource(tab.tabBarTitleRes), maxLines = 1) }, ) } NavigationRailItem( selected = AppTab.byId(selectedTabId)?.let { it !in pinnedTabs } == true, onClick = { showMore = true }, - icon = { Icon(Icons.Filled.MoreHoriz, contentDescription = "More destinations") }, - label = { Text("More") }, + icon = { Icon(Icons.Filled.MoreHoriz, contentDescription = null) }, + label = { Text(stringResource(R.string.nav_more)) }, ) NavigationRailItem( selected = selectedTabId == SETTINGS_ID, onClick = { onSelect(SETTINGS_ID) }, - icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, + icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, + label = { Text(stringResource(R.string.nav_settings)) }, ) } Box(Modifier.weight(1f).fillMaxHeight()) { @@ -121,9 +125,11 @@ internal fun AdaptiveNavigationShell( drawerContent = { PermanentDrawerSheet(Modifier.width(300.dp)) { Text( - text = "Arcane", + text = stringResource(R.string.brand_name), style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), + modifier = Modifier + .padding(horizontal = 20.dp, vertical = 18.dp) + .semantics { heading() }, ) LazyColumn( modifier = Modifier.weight(1f), @@ -134,15 +140,17 @@ internal fun AdaptiveNavigationShell( if (sectionTabs.isNotEmpty()) { item(key = "section-${section.name}") { Text( - text = section.title, + text = stringResource(section.titleRes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 28.dp, top = 14.dp, bottom = 4.dp), + modifier = Modifier + .padding(start = 28.dp, top = 14.dp, bottom = 4.dp) + .semantics { heading() }, ) } items(sectionTabs, key = { it.id }) { tab -> NavigationDrawerItem( - label = { Text(tab.title, maxLines = 1) }, + label = { Text(stringResource(tab.titleRes), maxLines = 1) }, selected = selectedTabId == tab.id, onClick = { onSelect(tab.id) }, icon = { Icon(tab.icon, contentDescription = null) }, @@ -154,7 +162,7 @@ internal fun AdaptiveNavigationShell( } HorizontalDivider() NavigationDrawerItem( - label = { Text("Settings") }, + label = { Text(stringResource(R.string.nav_settings)) }, selected = selectedTabId == SETTINGS_ID, onClick = { onSelect(SETTINGS_ID) }, icon = { Icon(Icons.Filled.Settings, contentDescription = null) }, @@ -171,9 +179,11 @@ internal fun AdaptiveNavigationShell( if (showMore) { ModalBottomSheet(onDismissRequest = { showMore = false }) { Text( - text = "All destinations", + text = stringResource(R.string.nav_all_destinations), style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 8.dp) + .semantics { heading() }, ) LazyColumn(Modifier.fillMaxHeight(0.75f)) { TabSection.entries.forEach { section -> @@ -181,15 +191,17 @@ internal fun AdaptiveNavigationShell( if (sectionTabs.isNotEmpty()) { item(key = "more-section-${section.name}") { Text( - text = section.title, + text = stringResource(section.titleRes), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 24.dp, top = 18.dp, bottom = 6.dp), + modifier = Modifier + .padding(start = 24.dp, top = 18.dp, bottom = 6.dp) + .semantics { heading() }, ) } items(sectionTabs, key = { "more-${it.id}" }) { tab -> NavigationDrawerItem( - label = { Text(tab.title) }, + label = { Text(stringResource(tab.titleRes)) }, selected = selectedTabId == tab.id, onClick = { showMore = false @@ -234,11 +246,13 @@ private fun RowScope.CompactNavigationItem( onClick: () -> Unit, onLongClick: () -> Unit, ) { + val tabBarTitle = stringResource(tab.tabBarTitleRes) + val customizeLabel = stringResource(R.string.nav_customize_tab_action, tabBarTitle) NavigationBarItem( modifier = Modifier .observeLongPress(onLongClick) .semantics { - onLongClick(label = "Customize ${tab.tabBarTitle} tab") { + onLongClick(label = customizeLabel) { onLongClick() true } @@ -248,10 +262,10 @@ private fun RowScope.CompactNavigationItem( icon = { Icon( tab.icon, - contentDescription = tab.title, + contentDescription = null, ) }, - label = { Text(tab.tabBarTitle, maxLines = 1) }, + label = { Text(tabBarTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) }, ) } @@ -266,7 +280,7 @@ private fun RowScope.CompactNavigationItem( selected = selected, onClick = onClick, icon = icon, - label = { Text(label, maxLines = 1) }, + label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis) }, ) } diff --git a/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt b/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt index cf36af8..9f2d176 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/AppTab.kt @@ -1,5 +1,6 @@ package app.getarcane.android.nav +import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AdminPanelSettings import androidx.compose.material.icons.filled.Autorenew @@ -25,6 +26,7 @@ import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material.icons.filled.Webhook import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import app.getarcane.android.R import app.getarcane.android.ui.theme.ArcaneBlue import app.getarcane.android.ui.theme.ArcaneCyan import app.getarcane.android.ui.theme.ArcaneGray @@ -38,11 +40,11 @@ import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.android.ui.theme.ArcaneTeal import app.getarcane.android.ui.theme.ArcaneYellow -enum class TabSection(val title: String) { - Management("Management"), - Resources("Resources"), - Swarm("Swarm"), - Administration("Administration"), +enum class TabSection(val title: String, @get:StringRes val titleRes: Int) { + Management("Management", R.string.nav_management_section), + Resources("Resources", R.string.nav_resources_section), + Swarm("Swarm", R.string.nav_swarm_section), + Administration("Administration", R.string.nav_administration_section), } /** The full tab registry. Port of iOS `AppTab` (title/icon/color/section/gating/env-scoping). */ @@ -50,6 +52,8 @@ enum class AppTab( val id: String, val title: String, val tabBarTitle: String, + @get:StringRes val titleRes: Int, + @get:StringRes val tabBarTitleRes: Int, val icon: ImageVector, val color: Color, val section: TabSection, @@ -57,35 +61,35 @@ enum class AppTab( val requiresV2: Boolean = false, val isEnvironmentScoped: Boolean = false, ) { - Dashboard("dashboard", "Dashboard", "Dashboard", Icons.Filled.SpaceDashboard, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true), - Projects("projects", "Projects", "Projects", Icons.Filled.FolderSpecial, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true), - ContainerRegistries("containerRegistries", "Container Registries", "Registries", Icons.Filled.Cloud, ArcanePurple, TabSection.Management, requiresAdmin = true), - TemplateRegistries("templateRegistries", "Template Registries", "Templates", Icons.Filled.Layers, ArcaneIndigo, TabSection.Management, requiresAdmin = true), - GitRepositories("gitRepositories", "Git Repositories", "Git Repos", Icons.Filled.Source, ArcaneIndigo, TabSection.Management, requiresAdmin = true), - GitOps("gitOps", "GitOps", "GitOps", Icons.Filled.Sync, ArcaneIndigo, TabSection.Management, requiresAdmin = true, isEnvironmentScoped = true), + Dashboard("dashboard", "Dashboard", "Dashboard", R.string.nav_dashboard, R.string.nav_dashboard, Icons.Filled.SpaceDashboard, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true), + Projects("projects", "Projects", "Projects", R.string.nav_projects, R.string.nav_projects, Icons.Filled.FolderSpecial, ArcaneBlue, TabSection.Management, isEnvironmentScoped = true), + ContainerRegistries("containerRegistries", "Container Registries", "Registries", R.string.nav_container_registries, R.string.nav_container_registries_short, Icons.Filled.Cloud, ArcanePurple, TabSection.Management, requiresAdmin = true), + TemplateRegistries("templateRegistries", "Template Registries", "Templates", R.string.nav_template_registries, R.string.nav_template_registries_short, Icons.Filled.Layers, ArcaneIndigo, TabSection.Management, requiresAdmin = true), + GitRepositories("gitRepositories", "Git Repositories", "Git Repos", R.string.nav_git_repositories, R.string.nav_git_repositories_short, Icons.Filled.Source, ArcaneIndigo, TabSection.Management, requiresAdmin = true), + GitOps("gitOps", "GitOps", "GitOps", R.string.nav_gitops, R.string.nav_gitops, Icons.Filled.Sync, ArcaneIndigo, TabSection.Management, requiresAdmin = true, isEnvironmentScoped = true), - Containers("containers", "Containers", "Containers", Icons.Filled.Inventory2, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true), - Images("images", "Images", "Images", Icons.Filled.Layers, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true), - Builds("builds", "Builds", "Builds", Icons.Filled.Build, ArcaneOrange, TabSection.Resources, requiresAdmin = true), - Updates("updates", "Updates", "Updates", Icons.Filled.Autorenew, ArcaneGreen, TabSection.Resources), - Networks("networks", "Networks", "Networks", Icons.Filled.Lan, ArcaneTeal, TabSection.Resources, isEnvironmentScoped = true), - Ports("ports", "Ports", "Ports", Icons.Filled.SettingsEthernet, ArcaneCyan, TabSection.Resources, isEnvironmentScoped = true), - Volumes("volumes", "Volumes", "Volumes", Icons.Filled.Storage, ArcaneOrange, TabSection.Resources, isEnvironmentScoped = true), - Jobs("jobs", "Jobs", "Jobs", Icons.Filled.Schedule, ArcanePink, TabSection.Resources, requiresAdmin = true, isEnvironmentScoped = true), - Activities("activities", "Activities", "Activity", Icons.Filled.History, ArcaneOrange, TabSection.Resources, requiresV2 = true), + Containers("containers", "Containers", "Containers", R.string.nav_containers, R.string.nav_containers, Icons.Filled.Inventory2, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true), + Images("images", "Images", "Images", R.string.nav_images, R.string.nav_images, Icons.Filled.Layers, ArcaneBlue, TabSection.Resources, isEnvironmentScoped = true), + Builds("builds", "Builds", "Builds", R.string.nav_builds, R.string.nav_builds, Icons.Filled.Build, ArcaneOrange, TabSection.Resources, requiresAdmin = true), + Updates("updates", "Updates", "Updates", R.string.nav_updates, R.string.nav_updates, Icons.Filled.Autorenew, ArcaneGreen, TabSection.Resources), + Networks("networks", "Networks", "Networks", R.string.nav_networks, R.string.nav_networks, Icons.Filled.Lan, ArcaneTeal, TabSection.Resources, isEnvironmentScoped = true), + Ports("ports", "Ports", "Ports", R.string.nav_ports, R.string.nav_ports, Icons.Filled.SettingsEthernet, ArcaneCyan, TabSection.Resources, isEnvironmentScoped = true), + Volumes("volumes", "Volumes", "Volumes", R.string.nav_volumes, R.string.nav_volumes, Icons.Filled.Storage, ArcaneOrange, TabSection.Resources, isEnvironmentScoped = true), + Jobs("jobs", "Jobs", "Jobs", R.string.nav_jobs, R.string.nav_jobs, Icons.Filled.Schedule, ArcanePink, TabSection.Resources, requiresAdmin = true, isEnvironmentScoped = true), + Activities("activities", "Activities", "Activity", R.string.nav_activities, R.string.nav_activities_short, Icons.Filled.History, ArcaneOrange, TabSection.Resources, requiresV2 = true), - Swarm("swarm", "Swarm", "Swarm", Icons.Filled.Hub, ArcaneMint, TabSection.Swarm, requiresAdmin = true), + Swarm("swarm", "Swarm", "Swarm", R.string.nav_swarm_section, R.string.nav_swarm_section, Icons.Filled.Hub, ArcaneMint, TabSection.Swarm, requiresAdmin = true), - Events("events", "Events", "Events", Icons.Filled.History, ArcaneRed, TabSection.Administration), - Variables("variables", "Global Variables", "Variables", Icons.Filled.Sync, ArcaneTeal, TabSection.Administration, requiresV2 = true), - Users("users", "Users", "Users", Icons.Filled.Groups, ArcaneBlue, TabSection.Administration, requiresAdmin = true), - ApiKeys("apiKeys", "API Keys", "API Keys", Icons.Filled.VpnKey, ArcaneYellow, TabSection.Administration, requiresAdmin = true), - Notifications("notifications", "Notifications", "Notifications", Icons.Filled.Notifications, ArcaneRed, TabSection.Administration, requiresAdmin = true), - Webhooks("webhooks", "Webhooks", "Webhooks", Icons.Filled.Webhook, ArcaneGreen, TabSection.Administration, requiresAdmin = true), - Authentication("authentication", "Authentication", "Auth", Icons.Filled.Lock, ArcaneBlue, TabSection.Administration, requiresAdmin = true), - Roles("roles", "Roles", "Roles", Icons.Filled.AdminPanelSettings, ArcanePurple, TabSection.Administration, requiresAdmin = true, requiresV2 = true), - OidcRoleMappings("oidcRoleMappings", "OIDC Role Mappings", "OIDC Roles", Icons.Filled.Groups, ArcaneIndigo, TabSection.Administration, requiresAdmin = true, requiresV2 = true), - SystemSettings("systemSettings", "System Settings", "System", Icons.Filled.Dns, ArcaneGray, TabSection.Administration, requiresAdmin = true), + Events("events", "Events", "Events", R.string.nav_events, R.string.nav_events, Icons.Filled.History, ArcaneRed, TabSection.Administration), + Variables("variables", "Global Variables", "Variables", R.string.nav_variables, R.string.nav_variables_short, Icons.Filled.Sync, ArcaneTeal, TabSection.Administration, requiresV2 = true), + Users("users", "Users", "Users", R.string.nav_users, R.string.nav_users, Icons.Filled.Groups, ArcaneBlue, TabSection.Administration, requiresAdmin = true), + ApiKeys("apiKeys", "API Keys", "API Keys", R.string.nav_api_keys, R.string.nav_api_keys, Icons.Filled.VpnKey, ArcaneYellow, TabSection.Administration, requiresAdmin = true), + Notifications("notifications", "Notifications", "Notifications", R.string.nav_notifications, R.string.nav_notifications, Icons.Filled.Notifications, ArcaneRed, TabSection.Administration, requiresAdmin = true), + Webhooks("webhooks", "Webhooks", "Webhooks", R.string.nav_webhooks, R.string.nav_webhooks, Icons.Filled.Webhook, ArcaneGreen, TabSection.Administration, requiresAdmin = true), + Authentication("authentication", "Authentication", "Auth", R.string.nav_authentication, R.string.nav_authentication_short, Icons.Filled.Lock, ArcaneBlue, TabSection.Administration, requiresAdmin = true), + Roles("roles", "Roles", "Roles", R.string.nav_roles, R.string.nav_roles, Icons.Filled.AdminPanelSettings, ArcanePurple, TabSection.Administration, requiresAdmin = true, requiresV2 = true), + OidcRoleMappings("oidcRoleMappings", "OIDC Role Mappings", "OIDC Roles", R.string.nav_oidc_role_mappings, R.string.nav_oidc_role_mappings_short, Icons.Filled.Groups, ArcaneIndigo, TabSection.Administration, requiresAdmin = true, requiresV2 = true), + SystemSettings("systemSettings", "System Settings", "System", R.string.nav_system_settings, R.string.nav_system_settings_short, Icons.Filled.Dns, ArcaneGray, TabSection.Administration, requiresAdmin = true), ; val canPinToBottomBar: Boolean diff --git a/app/src/main/kotlin/app/getarcane/android/nav/TabSwapSheet.kt b/app/src/main/kotlin/app/getarcane/android/nav/TabSwapSheet.kt index a9adca4..0bda06e 100644 --- a/app/src/main/kotlin/app/getarcane/android/nav/TabSwapSheet.kt +++ b/app/src/main/kotlin/app/getarcane/android/nav/TabSwapSheet.kt @@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import app.getarcane.android.R import app.getarcane.android.core.LocalArcaneManager import app.getarcane.sdk.ServerCapabilities import app.getarcane.sdk.models.user.isGlobalAdmin @@ -63,15 +65,21 @@ fun TabSwapSheet( verticalArrangement = Arrangement.spacedBy(20.dp), ) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text("Replace ${current.title}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - TextButton(onClick = onReset, enabled = tabsStore.pinned != AppTab.defaults) { Text("Reset") } + Text( + stringResource(R.string.nav_replace_tab_title, stringResource(current.titleRes)), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + TextButton(onClick = onReset, enabled = tabsStore.pinned != AppTab.defaults) { + Text(stringResource(R.string.nav_reset_action)) + } } TabSection.entries.forEach { section -> val tabs = eligible(section) if (tabs.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( - section.title.uppercase(), + stringResource(section.titleRes), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 4.dp), @@ -107,7 +115,7 @@ private fun TabTile(tab: AppTab, modifier: Modifier = Modifier, onClick: () -> U Icon(tab.icon, null, tint = Color.White, modifier = Modifier.size(22.dp)) } Text( - tab.tabBarTitle, + stringResource(tab.tabBarTitleRes), style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, maxLines = 2, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/ArcaneApp.kt b/app/src/main/kotlin/app/getarcane/android/ui/ArcaneApp.kt index 1c389f5..ae773a5 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/ArcaneApp.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/ArcaneApp.kt @@ -10,6 +10,10 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import app.getarcane.android.R import app.getarcane.android.core.AuthStatus import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.nav.MainTabView @@ -21,10 +25,11 @@ import app.getarcane.android.ui.operations.OperationHost @Composable fun ArcaneApp() { val manager = LocalArcaneManager.current - when (manager.authStatus) { - AuthStatus.AUTHENTICATING -> LoadingScreen() - AuthStatus.SETUP, AuthStatus.LOGIN -> LoginScreen() - AuthStatus.AUTHENTICATED -> { + AuthRouteContent( + authStatus = manager.authStatus, + authenticatingContent = { LoadingScreen() }, + loginContent = { LoginScreen() }, + authenticatedContent = { // When a demo is active, the banner sits above the tab shell (iOS ContentView VStack). // Consume the status-bar inset at this level so the banner drops below the notch/clock // and the tab shell below doesn't double-inset. With no demo, leave the inset for @@ -40,13 +45,34 @@ fun ArcaneApp() { Box(Modifier.weight(1f)) { MainTabView() } } } - } + }, + ) +} + +/** Deterministic root routing contract used by the app and focused instrumentation tests. */ +@Composable +internal fun AuthRouteContent( + authStatus: AuthStatus, + authenticatingContent: @Composable () -> Unit, + loginContent: @Composable () -> Unit, + authenticatedContent: @Composable () -> Unit, +) { + when (authStatus) { + AuthStatus.AUTHENTICATING -> authenticatingContent() + AuthStatus.SETUP, AuthStatus.LOGIN -> loginContent() + AuthStatus.AUTHENTICATED -> authenticatedContent() } } @Composable private fun LoadingScreen() { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + val loadingDescription = stringResource(R.string.auth_loading) + Box( + Modifier + .fillMaxSize() + .semantics { contentDescription = loadingDescription }, + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt index 69db2bd..4a506b5 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/auth/LoginScreen.kt @@ -80,8 +80,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalAutofill import androidx.compose.ui.platform.LocalAutofillTree import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -261,7 +262,7 @@ private fun Header(isSetup: Boolean, isStartingDemo: Boolean, brand: Color) { ) { Image( painter = painterResource(R.drawable.arcane_logo), - contentDescription = "Arcane", + contentDescription = stringResource(R.string.brand_name), modifier = Modifier.size(68.dp), ) } @@ -270,10 +271,10 @@ private fun Header(isSetup: Boolean, isStartingDemo: Boolean, brand: Color) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text("Arcane", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.brand_name), style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) val subtitle = when { - isStartingDemo -> "Setting things up for you…" - isSetup -> "Connect to your Arcane server" + isStartingDemo -> stringResource(R.string.auth_starting_demo) + isSetup -> stringResource(R.string.auth_connect_subtitle) else -> null } subtitle?.let { @@ -291,17 +292,17 @@ private fun SetupFields(value: String, onValueChange: (String) -> Unit, onGo: () FieldCard { FieldRow( icon = Icons.Filled.Dns, - label = "Server URL", + label = stringResource(R.string.auth_server_url_label), value = value, onValueChange = onValueChange, - placeholder = "https://arcane.example.com", + placeholder = stringResource(R.string.auth_server_url_placeholder), keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go, keyboardActions = KeyboardActions(onGo = { onGo() }), ) } Text( - "For a local server, include the scheme — e.g. http://192.168.1.50:3000", + stringResource(R.string.auth_local_server_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -321,6 +322,7 @@ private fun CredentialsFields( onSubmit: () -> Unit, ) { val passwordFocus = remember { FocusRequester() } + val passwordLabel = stringResource(R.string.auth_password_label) FieldCard { // Server (read-only) row. Column( @@ -329,7 +331,7 @@ private fun CredentialsFields( ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { Icon(Icons.Filled.Dns, null, Modifier.size(14.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Text("Server", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.auth_server_label), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant) } Text( manager.serverUrl, @@ -344,10 +346,10 @@ private fun CredentialsFields( HorizontalDivider(Modifier.padding(start = 16.dp)) FieldRow( icon = Icons.Filled.Person, - label = "Username", + label = stringResource(R.string.auth_username_label), value = username, onValueChange = onUsername, - placeholder = "Username", + placeholder = stringResource(R.string.auth_username_label), imeAction = ImeAction.Next, keyboardActions = KeyboardActions(onNext = { passwordFocus.requestFocus() }), autofillTypes = listOf(AutofillType.Username), @@ -355,16 +357,15 @@ private fun CredentialsFields( HorizontalDivider(Modifier.padding(start = 16.dp)) FieldRow( icon = Icons.Filled.Lock, - label = "Password", + label = passwordLabel, value = password, onValueChange = onPassword, - placeholder = "Password", + placeholder = passwordLabel, imeAction = ImeAction.Go, keyboardActions = KeyboardActions(onGo = { onSubmit() }), visualTransformation = PasswordVisualTransformation(), textFieldModifier = Modifier - .focusRequester(passwordFocus) - .clearAndSetSemantics { contentDescription = "Password" }, + .focusRequester(passwordFocus), autofillTypes = listOf(AutofillType.Password), ) } @@ -392,7 +393,7 @@ private fun Actions( Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { if (isSetup) { PrimaryButton( - text = "Connect", + text = stringResource(R.string.auth_connect_action), icon = Icons.AutoMirrored.Filled.ArrowForward, enabled = connectEnabled && !manager.isLoading, loading = manager.isLoading && !manager.isStartingDemo, @@ -401,7 +402,7 @@ private fun Actions( } else { if (manager.passkeyLoginState == PasskeyLoginState.AVAILABLE) { PrimaryButton( - text = "Sign in with a passkey", + text = stringResource(R.string.auth_sign_in_passkey_action), icon = Icons.Filled.VpnKey, enabled = !manager.isLoading, loading = false, @@ -410,7 +411,11 @@ private fun Actions( } if (manager.isOidcAvailable && !showPasswordForm) { PrimaryButton( - text = "Continue with ${manager.oidc?.providerName?.takeIf { it.isNotBlank() } ?: "OIDC"}", + text = stringResource( + R.string.auth_continue_provider_action, + manager.oidc?.providerName?.takeIf { it.isNotBlank() } + ?: stringResource(R.string.auth_default_oidc_provider), + ), icon = Icons.Filled.VpnKey, enabled = !manager.isLoading, loading = false, @@ -426,12 +431,16 @@ private fun Actions( ) { Icon(if (showPasswordForm) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) - Text(if (showPasswordForm) "Hide password sign in" else "Sign in with username and password") + Text( + stringResource( + if (showPasswordForm) R.string.auth_hide_password_action else R.string.auth_show_password_action, + ), + ) } } if (showPassword) { PrimaryButton( - text = "Sign In", + text = stringResource(R.string.auth_sign_in_action), icon = Icons.AutoMirrored.Filled.Login, enabled = signInEnabled && !manager.isLoading, loading = manager.isLoading, @@ -442,10 +451,10 @@ private fun Actions( TextButton( onClick = manager::cancelPasskeyBrowserOperation, modifier = Modifier.fillMaxWidth(), - ) { Text("Cancel passkey request") } + ) { Text(stringResource(R.string.auth_cancel_passkey_action)) } } TextButton(onClick = onChangeServer, modifier = Modifier.fillMaxWidth()) { - Text("Change Server", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.auth_change_server_action), color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -459,20 +468,20 @@ private fun MfaChallengeContent(manager: ArcaneClientManager) { ProtectSensitiveWindow() Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "Two-factor authentication", + stringResource(R.string.auth_mfa_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, ) Text( - "Use a registered passkey or one of your recovery codes.", + stringResource(R.string.auth_mfa_instructions), color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), ) PrimaryButton( - text = "Continue with passkey", + text = stringResource(R.string.auth_mfa_continue_passkey_action), icon = Icons.Filled.VpnKey, enabled = manager.passkeyBridgeState == PasskeyLoginState.AVAILABLE && !manager.isLoading, loading = manager.isLoading && recoveryCode.isEmpty(), @@ -480,7 +489,7 @@ private fun MfaChallengeContent(manager: ArcaneClientManager) { ) if (manager.passkeyBridgeState != PasskeyLoginState.AVAILABLE) { Text( - "Passkey verification is unavailable for this server connection. Use a recovery code.", + stringResource(R.string.auth_mfa_passkey_unavailable), color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), @@ -490,12 +499,12 @@ private fun MfaChallengeContent(manager: ArcaneClientManager) { TextButton( onClick = manager::cancelPasskeyBrowserOperation, modifier = Modifier.fillMaxWidth(), - ) { Text("Cancel passkey request") } + ) { Text(stringResource(R.string.auth_cancel_passkey_action)) } } SensitiveOutlinedField( value = recoveryCode, onValueChange = { recoveryCode = it }, - label = "Recovery code", + label = stringResource(R.string.auth_recovery_code_label), enabled = !manager.isLoading, ) OutlinedButton( @@ -506,7 +515,7 @@ private fun MfaChallengeContent(manager: ArcaneClientManager) { }, enabled = recoveryCode.isNotBlank() && !manager.isLoading, modifier = Modifier.fillMaxWidth().height(48.dp), - ) { Text("Use recovery code") } + ) { Text(stringResource(R.string.auth_use_recovery_code_action)) } TextButton( onClick = { recoveryCode = "" @@ -514,7 +523,7 @@ private fun MfaChallengeContent(manager: ArcaneClientManager) { }, enabled = !manager.isLoading, modifier = Modifier.fillMaxWidth(), - ) { Text("Back to sign in") } + ) { Text(stringResource(R.string.auth_back_to_sign_in_action)) } } } @@ -559,12 +568,12 @@ private fun DemoCard(manager: ArcaneClientManager, brand: Color) { } Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( - if (starting) "Starting demo…" else "Try the demo", + stringResource(if (starting) R.string.auth_demo_starting else R.string.auth_demo_action), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) Text( - if (starting) "This usually takes about 30 seconds." else "Temporary instance for ~10 minutes. No account needed.", + stringResource(if (starting) R.string.auth_demo_starting_detail else R.string.auth_demo_detail), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -631,6 +640,7 @@ private fun FieldRow( visualTransformation = visualTransformation, modifier = textFieldModifier .fillMaxWidth() + .semantics { contentDescription = label } .autofill(autofillTypes, onValueChange), decorationBox = { inner -> if (value.isEmpty()) { @@ -695,7 +705,12 @@ private fun InfoBanner(message: String, onDismiss: () -> Unit) { Icon(Icons.Filled.Info, null, tint = MaterialTheme.colorScheme.primary) Text(message, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) IconButton(onClick = onDismiss) { - Icon(Icons.Filled.Close, "Dismiss", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + Icon( + Icons.Filled.Close, + stringResource(R.string.a11y_dismiss), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/components/DemoBanner.kt b/app/src/main/kotlin/app/getarcane/android/ui/components/DemoBanner.kt index 6db6e16..e72da25 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/components/DemoBanner.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/components/DemoBanner.kt @@ -77,7 +77,7 @@ fun DemoBanner() { Column(Modifier.weight(1f)) { Text("Demo Mode", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) Text( - String.format("%d:%02d remaining", minutes, seconds), + String.format(java.util.Locale.getDefault(), "%d:%02d remaining", minutes, seconds), style = MaterialTheme.typography.labelMedium, fontFamily = FontFamily.Monospace, color = if (isLowTime) ArcaneOrange else MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/components/DestructiveConfirmationDialog.kt b/app/src/main/kotlin/app/getarcane/android/ui/components/DestructiveConfirmationDialog.kt new file mode 100644 index 0000000..92a844f --- /dev/null +++ b/app/src/main/kotlin/app/getarcane/android/ui/components/DestructiveConfirmationDialog.kt @@ -0,0 +1,38 @@ +package app.getarcane.android.ui.components + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import app.getarcane.android.R + +/** Shared, testable confirmation contract for a single destructive or disruptive action. */ +@Composable +fun DestructiveConfirmationDialog( + title: String, + message: String, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + destructive: Boolean = true, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text( + confirmLabel, + color = if (destructive) MaterialTheme.colorScheme.error else Color.Unspecified, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } + }, + ) +} diff --git a/app/src/main/kotlin/app/getarcane/android/ui/components/ErrorBanner.kt b/app/src/main/kotlin/app/getarcane/android/ui/components/ErrorBanner.kt index 49b24c2..5cad738 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/components/ErrorBanner.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/components/ErrorBanner.kt @@ -17,7 +17,13 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import app.getarcane.android.R import app.getarcane.android.ui.theme.ArcaneOrange import app.getarcane.android.ui.theme.ArcaneRed @@ -38,6 +44,10 @@ fun ErrorBanner( .clip(RoundedCornerShape(12.dp)) .background(tint.copy(alpha = 0.12f)) .border(1.dp, tint.copy(alpha = 0.18f), RoundedCornerShape(12.dp)) + .semantics { + liveRegion = LiveRegionMode.Polite + if (severity == BannerSeverity.Error) error(message) + } .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -50,7 +60,7 @@ fun ErrorBanner( modifier = Modifier.weight(1f), ) if (onRetry != null) { - TextButton(onClick = onRetry) { Text("Retry") } + TextButton(onClick = onRetry) { Text(stringResource(R.string.action_retry)) } } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/components/FormFieldRows.kt b/app/src/main/kotlin/app/getarcane/android/ui/components/FormFieldRows.kt index bbffea2..84f67d9 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/components/FormFieldRows.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/components/FormFieldRows.kt @@ -81,7 +81,7 @@ fun FormTextField( keyboardOptions = KeyboardOptions( keyboardType = keyboardType, capitalization = if (autoCapitalize) KeyboardCapitalization.Sentences else KeyboardCapitalization.None, - autoCorrect = autoCorrect, + autoCorrectEnabled = autoCorrect, ), modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/kotlin/app/getarcane/android/ui/components/ResourceStatusBadge.kt b/app/src/main/kotlin/app/getarcane/android/ui/components/ResourceStatusBadge.kt index a3e76fb..7f5806c 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/components/ResourceStatusBadge.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/components/ResourceStatusBadge.kt @@ -27,6 +27,7 @@ import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.ArcaneOrange import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.android.ui.theme.ArcaneYellow +import app.getarcane.android.ui.theme.accessibleOnSurface /** * A capsule status pill with a leading status icon and tinted background/border. @@ -45,7 +46,7 @@ fun ResourceStatusBadge( val live = isLive ?: (normalized == "running" || normalized == "online") val unknownTint = MaterialTheme.colorScheme.onSurfaceVariant - val tint: Color = when { + val semanticTint: Color = when { live -> ArcaneGreen normalized in setOf("running", "online", "success", "completed", "done") -> ArcaneGreen normalized in setOf("partial", "partially running") -> ArcaneOrange @@ -54,6 +55,7 @@ fun ResourceStatusBadge( normalized == "paused" -> ArcaneYellow else -> unknownTint } + val tint = accessibleOnSurface(semanticTint) val icon: ImageVector = when { live -> Icons.Filled.CheckCircle diff --git a/app/src/main/kotlin/app/getarcane/android/ui/components/StaleDataBanner.kt b/app/src/main/kotlin/app/getarcane/android/ui/components/StaleDataBanner.kt index fe60753..c099198 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/components/StaleDataBanner.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/components/StaleDataBanner.kt @@ -12,6 +12,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import java.util.concurrent.TimeUnit @@ -36,6 +39,7 @@ fun StaleDataBanner(info: StaleDataInfo, nowEpochMs: Long = System.currentTimeMi modifier = Modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.tertiaryContainer) + .semantics { liveRegion = LiveRegionMode.Polite } .padding(horizontal = 16.dp, vertical = 8.dp), ) { androidx.compose.foundation.layout.Row { diff --git a/app/src/main/kotlin/app/getarcane/android/ui/components/StatRing.kt b/app/src/main/kotlin/app/getarcane/android/ui/components/StatRing.kt index 53fc018..c2e78cf 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/components/StatRing.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/components/StatRing.kt @@ -16,10 +16,16 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import app.getarcane.android.R /** Animated circular progress ring with a centered value + label below. Port of iOS `StatRing`. */ @Composable @@ -31,8 +37,17 @@ fun StatRing( size: Dp = 62.dp, lineWidth: Dp = 7.dp, ) { - val animated by animateFloatAsState(value.coerceIn(0f, 1f), label = "ring") - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + val boundedValue = value.coerceIn(0f, 1f) + val animated by animateFloatAsState(boundedValue, label = "ring") + val accessibilityValue = stringResource(R.string.a11y_stat_value, label, valueText) + Column( + modifier = Modifier.semantics(mergeDescendants = true) { + contentDescription = accessibilityValue + progressBarRangeInfo = ProgressBarRangeInfo(boundedValue, 0f..1f) + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Box(Modifier.size(size), contentAlignment = Alignment.Center) { Canvas(Modifier.size(size)) { val stroke = lineWidth.toPx() diff --git a/app/src/main/kotlin/app/getarcane/android/ui/operations/OperationCenter.kt b/app/src/main/kotlin/app/getarcane/android/ui/operations/OperationCenter.kt index 7f2fd8a..47be514 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/operations/OperationCenter.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/operations/OperationCenter.kt @@ -37,10 +37,18 @@ 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.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import app.getarcane.android.R import app.getarcane.android.core.LocalOperationStore import app.getarcane.android.core.OperationRecord import app.getarcane.android.core.OperationState @@ -94,7 +102,17 @@ private fun OperationIndicator( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - Card(onClick = onClick, modifier = modifier.fillMaxWidth()) { + val operationSummary = pluralStringResource(R.plurals.operation_in_progress_count, count, count) + val reconnectingSummary = if (reconnecting) stringResource(R.string.operation_reconnecting) else null + Card( + onClick = onClick, + modifier = modifier + .fillMaxWidth() + .semantics { + liveRegion = LiveRegionMode.Polite + stateDescription = reconnectingSummary?.let { "$operationSummary. $it" } ?: operationSummary + }, + ) { Row( Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -102,10 +120,10 @@ private fun OperationIndicator( ) { CircularProgressIndicator(modifier = Modifier.padding(2.dp), strokeWidth = 2.dp) Column(Modifier.weight(1f)) { - Text(if (count == 1) "1 operation in progress" else "$count operations in progress", fontWeight = FontWeight.SemiBold) - if (reconnecting) Text("Reconnecting to server activity", style = MaterialTheme.typography.bodySmall) + Text(operationSummary, fontWeight = FontWeight.SemiBold) + if (reconnectingSummary != null) Text(reconnectingSummary, style = MaterialTheme.typography.bodySmall) } - Text("View", color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.action_view), color = MaterialTheme.colorScheme.primary) } } } @@ -139,12 +157,17 @@ private fun OperationCenterSheet() { private fun OperationList() { val store = LocalOperationStore.current Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { - Text("Operations", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.operation_center_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() }, + ) store.unavailableMessage?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(top = 8.dp)) } if (store.operations.isEmpty()) { - Text("No recent operations", modifier = Modifier.padding(vertical = 32.dp)) + Text(stringResource(R.string.operation_center_empty), modifier = Modifier.padding(vertical = 32.dp)) } else { LazyColumn( modifier = Modifier.fillMaxWidth().heightIn(max = 600.dp).padding(vertical = 12.dp), @@ -181,23 +204,32 @@ fun OperationDetail( canCancel: Boolean, ) { Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp)) { - TextButton(onClick = onBack) { Text("Back to operations") } - Text(record.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + TextButton(onClick = onBack) { Text(stringResource(R.string.action_back_to_operations)) } + Text( + record.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() }, + ) Text(record.state.displayName(), modifier = Modifier.padding(top = 4.dp)) record.progressPercent?.let { LinearProgressIndicator(progress = { it / 100f }, modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp)) } record.detailMessage?.let { Text(it, style = MaterialTheme.typography.bodySmall) } Row(Modifier.padding(vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (canCancel) OutlinedButton(onClick = onCancel) { Text("Cancel") } + if (canCancel) OutlinedButton(onClick = onCancel) { Text(stringResource(R.string.action_cancel)) } if (record.state == OperationState.UNKNOWN || record.state == OperationState.INTERRUPTED) { - OutlinedButton(onClick = onRetry) { Text("Check again") } + OutlinedButton(onClick = onRetry) { Text(stringResource(R.string.action_check_again)) } } - if (record.serverActivityId != null) OutlinedButton(onClick = onOpenActivity) { Text("Activity Center") } - if (record.isTerminalLike) Button(onClick = onDismiss) { Text("Dismiss") } + if (record.serverActivityId != null) OutlinedButton(onClick = onOpenActivity) { Text(stringResource(R.string.action_activity_center)) } + if (record.isTerminalLike) Button(onClick = onDismiss) { Text(stringResource(R.string.action_dismiss)) } } if (record.lines.isNotEmpty()) { - Text("Output", fontWeight = FontWeight.SemiBold) + Text( + stringResource(R.string.operation_output_heading), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.semantics { heading() }, + ) LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 360.dp).padding(vertical = 8.dp)) { itemsIndexed(record.lines) { index, line -> Text( @@ -212,16 +244,17 @@ fun OperationDetail( } } -private fun OperationState.displayName(): String = when (this) { - OperationState.QUEUED -> "Queued" - OperationState.STARTING -> "Starting" - OperationState.RUNNING -> "Running" - OperationState.RECONNECTING -> "Reconnecting" - OperationState.CANCEL_REQUESTED -> "Cancellation requested" - OperationState.SUCCESS -> "Completed" - OperationState.FAILURE -> "Failed" - OperationState.CANCELLED -> "Cancelled" - OperationState.INTERRUPTED -> "Interrupted" - OperationState.UNKNOWN -> "Outcome unknown" - OperationState.CLEARED -> "Cleared" -} +@Composable +private fun OperationState.displayName(): String = stringResource(when (this) { + OperationState.QUEUED -> R.string.operation_state_queued + OperationState.STARTING -> R.string.operation_state_starting + OperationState.RUNNING -> R.string.operation_state_running + OperationState.RECONNECTING -> R.string.operation_state_reconnecting + OperationState.CANCEL_REQUESTED -> R.string.operation_state_cancel_requested + OperationState.SUCCESS -> R.string.operation_state_completed + OperationState.FAILURE -> R.string.operation_state_failed + OperationState.CANCELLED -> R.string.operation_state_cancelled + OperationState.INTERRUPTED -> R.string.operation_state_interrupted + OperationState.UNKNOWN -> R.string.operation_state_unknown + OperationState.CLEARED -> R.string.operation_state_cleared +}) diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt index d90d0f5..0b011d1 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardPinnedSection.kt @@ -1,5 +1,6 @@ package app.getarcane.android.ui.screens +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable @@ -43,9 +44,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.getarcane.android.R import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.LocalPinnedStore import app.getarcane.android.core.LocalOperationStore @@ -68,6 +74,7 @@ import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.android.ui.theme.ArcaneTeal import app.getarcane.android.ui.theme.StatusRunning import app.getarcane.android.ui.theme.StatusUnknown +import app.getarcane.android.ui.theme.accessibleOnSurface import app.getarcane.sdk.models.container.ContainerSummary import app.getarcane.sdk.models.project.ProjectDetails import app.getarcane.sdk.models.volume.Volume as SdkVolume @@ -238,6 +245,8 @@ fun DashboardPinnedSection( is DashboardPinnedItem.Volume -> Unit } reloadKey++ + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { onMessage(friendlyErrorMessage(e)) } finally { @@ -279,7 +288,8 @@ private fun DashboardPinnedRow( indication = null, onClick = onOpen, onLongClick = { menu = true }, - ), + ) + .semantics { role = Role.Button }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -292,7 +302,7 @@ private fun DashboardPinnedRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - ResourceStatusBadge(status = item.status) + ResourceStatusBadge(status = item.localizedStatus()) } Icon( Icons.AutoMirrored.Filled.KeyboardArrowRight, @@ -301,7 +311,7 @@ private fun DashboardPinnedRow( modifier = Modifier.size(18.dp), ) } - val actionTitle = item.actionTitle + val actionTitle = item.actionTitleRes?.let { stringResource(it) } if (actionTitle != null) { IconButton(onClick = onAction, enabled = actionsEnabled) { if (busy) { @@ -310,18 +320,18 @@ private fun DashboardPinnedRow( Icon( if (item.isRunning) Icons.Filled.Stop else Icons.Filled.PlayArrow, contentDescription = actionTitle, - tint = if (item.isRunning) ArcaneRed else ArcaneGreen, + tint = accessibleOnSurface(if (item.isRunning) ArcaneRed else ArcaneGreen), ) } } } IconButton(onClick = { menu = true }) { - Icon(Icons.Filled.MoreVert, contentDescription = "Pinned item actions") + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.a11y_pinned_item_actions)) } } DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { DropdownMenuItem( - text = { Text("Open") }, + text = { Text(stringResource(R.string.action_open)) }, onClick = { menu = false onOpen() @@ -329,7 +339,7 @@ private fun DashboardPinnedRow( leadingIcon = { Icon(Icons.AutoMirrored.Filled.OpenInNew, null) }, ) DropdownMenuItem( - text = { Text("Unpin") }, + text = { Text(stringResource(R.string.action_unpin)) }, onClick = { menu = false onUnpin() @@ -362,42 +372,51 @@ private fun DashboardPinnedIcon(icon: ImageVector, tint: Color, isRunning: Boole private sealed interface DashboardPinnedItem { val key: String val title: String - val status: String val isRunning: Boolean val icon: ImageVector val tint: Color - val actionTitle: String? + @get:StringRes val actionTitleRes: Int? data class Container(val value: ContainerSummary) : DashboardPinnedItem { override val key: String = "container-${value.id}" override val title: String = value.displayName - override val status: String = if (value.isRunning) "Running" else "Stopped" override val isRunning: Boolean = value.isRunning override val icon: ImageVector = Icons.Filled.Inventory2 override val tint: Color = ArcaneOrange - override val actionTitle: String = if (isRunning) "Stop Container" else "Start Container" + override val actionTitleRes: Int = + if (isRunning) R.string.pinned_stop_container else R.string.pinned_start_container } data class Project(val value: ProjectDetails) : DashboardPinnedItem { override val key: String = "project-${value.id}" override val title: String = value.name - override val status: String = value.status override val isRunning: Boolean = value.isDashboardRunning override val icon: ImageVector = Icons.Filled.Layers override val tint: Color = ArcaneBlue - override val actionTitle: String = if (isRunning) "Stop Project" else "Deploy Project" + override val actionTitleRes: Int = + if (isRunning) R.string.pinned_stop_project else R.string.pinned_deploy_project } data class Volume(val value: SdkVolume) : DashboardPinnedItem { override val key: String = "volume-${value.id}" override val title: String = value.name - override val status: String = if (value.inUse) "In use" else "Unused" override val isRunning: Boolean = value.inUse override val icon: ImageVector = Icons.Filled.Storage override val tint: Color = ArcaneTeal - override val actionTitle: String? = null + override val actionTitleRes: Int? = null } } +@Composable +private fun DashboardPinnedItem.localizedStatus(): String = when (this) { + is DashboardPinnedItem.Container -> stringResource( + if (value.isRunning) R.string.status_running else R.string.status_stopped, + ) + is DashboardPinnedItem.Project -> value.status + is DashboardPinnedItem.Volume -> stringResource( + if (value.inUse) R.string.status_in_use else R.string.status_unused, + ) +} + private val ProjectDetails.isDashboardRunning: Boolean get() = status.equals("running", ignoreCase = true) diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt index 7cc9d1b..4624dfe 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/DashboardScreen.kt @@ -59,6 +59,14 @@ 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.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight @@ -72,6 +80,7 @@ import app.getarcane.android.core.loadCompleteEnvironments import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import app.getarcane.android.BuildConfig +import app.getarcane.android.R import app.getarcane.android.core.formatBytes import app.getarcane.android.core.ArcaneDashboardStreamClient import app.getarcane.android.core.DashboardActionItemKind @@ -109,6 +118,7 @@ import app.getarcane.android.ui.theme.ArcaneOrange import app.getarcane.android.ui.theme.ArcanePurple import app.getarcane.android.ui.theme.ArcaneRed import app.getarcane.android.ui.theme.ArcaneTeal +import app.getarcane.android.ui.theme.accessibleOnSurface import app.getarcane.sdk.EnvironmentId import app.getarcane.sdk.models.activity.ActivityStatus import app.getarcane.sdk.models.base.SortOrder @@ -205,6 +215,7 @@ fun DashboardScreen( onOpenApiKeys: (() -> Unit)? = null, ) { val manager = LocalArcaneManager.current + val context = LocalContext.current val client = manager.client val envId = manager.activeEnvironmentId @@ -230,6 +241,7 @@ fun DashboardScreen( var pruneEnvironmentId by remember { mutableStateOf(null) } var upgradeEnvironment by remember { mutableStateOf(null) } val statsHistory = remember { mutableStateMapOf() } + val syncingEnvironments = remember { mutableStateMapOf() } val scope = rememberCoroutineScope() val streamClient = remember(client) { client?.let(::ArcaneDashboardStreamClient) } val streamStore = remember(scope) { DashboardStreamStore(scope) } @@ -462,7 +474,7 @@ fun DashboardScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Dashboard") }, + title = { Text(stringResource(R.string.nav_dashboard)) }, actions = { if (supportsActivities) { IconButton(onClick = { showActivities = true }) { @@ -471,7 +483,11 @@ fun DashboardScreen( } if (canPruneActiveEnvironment) { IconButton(onClick = { pruneEnvironmentId = envId }) { - Icon(Icons.Filled.Delete, contentDescription = "System Prune", tint = ArcaneRed) + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.action_system_prune), + tint = ArcaneRed, + ) } } }, @@ -571,11 +587,11 @@ fun DashboardScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Text("Environments", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.dashboard_environments), style = MaterialTheme.typography.titleMedium) if (shouldShowUpdateAllAction(isAdmin)) { Button(onClick = { showUpdateAll = true }) { Icon(Icons.Filled.ArrowCircleUp, contentDescription = null) - Text(" Update All") + Text(stringResource(R.string.update_all_action), modifier = Modifier.padding(start = 8.dp)) } } } @@ -589,6 +605,7 @@ fun DashboardScreen( statsSeries = statsHistory[env.id], versionInfo = streamState?.snapshot?.versionInfo, refreshToken = refreshKey, + syncing = syncingEnvironments[env.id] == true, onSelect = { manager.setActiveEnvironment(EnvironmentId(env.id), env.name ?: env.id) }, actions = environmentCardActions( canPrune = currentUser.canPruneEnvironment(env.id), @@ -602,8 +619,34 @@ fun DashboardScreen( onOpenEnvironmentDetails?.invoke(env.id) } EnvironmentCardAction.Sync -> { - refreshKey++ - scope.launch { snackbar.showSnackbar("Refreshing ${env.name ?: env.id}") } + if (syncingEnvironments[env.id] == true) return@EnvironmentDashboardCard + val captured = manager.authenticatedClientScope() + ?: return@EnvironmentDashboardCard + scope.launch { + val environmentName = env.name ?: env.id + syncingEnvironments[env.id] = true + snackbar.showSnackbar( + context.getString(R.string.environment_syncing, environmentName), + ) + try { + captured.client.environments.sync(EnvironmentId(env.id)) + if (manager.isCurrent(captured)) { + manager.invalidateReadCache(EnvironmentId(env.id)) + refreshKey++ + snackbar.showSnackbar( + context.getString(R.string.environment_synced, environmentName), + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + if (manager.isCurrent(captured)) { + snackbar.showSnackbar(friendlyErrorMessage(e)) + } + } finally { + syncingEnvironments.remove(env.id) + } + } } EnvironmentCardAction.UpgradeArcane -> { upgradeEnvironment = env @@ -746,13 +789,18 @@ private suspend fun loadLegacyDashboardTotals( @Composable private fun ActivityCenterToolbarIcon(failedCount: Int) { + val contentDescription = if (failedCount > 0) { + pluralStringResource(R.plurals.a11y_failed_activities, failedCount, failedCount) + } else { + stringResource(R.string.a11y_activity_center) + } Box( modifier = Modifier.size(30.dp), contentAlignment = Alignment.Center, ) { Icon( Icons.Filled.History, - contentDescription = activityCenterButtonContentDescription(failedCount), + contentDescription = contentDescription, modifier = Modifier.size(24.dp), ) @@ -771,7 +819,8 @@ private fun BoxScope.ActivityCenterFailedBadge(failedCount: Int) { .height(18.dp) .widthIn(min = 18.dp) .background(ArcaneRed, CircleShape) - .padding(horizontal = if (failedCount > 9) 4.dp else 0.dp), + .padding(horizontal = if (failedCount > 9) 4.dp else 0.dp) + .clearAndSetSemantics {}, contentAlignment = Alignment.Center, ) { Text( @@ -789,23 +838,18 @@ private fun BoxScope.ActivityCenterFailedBadge(failedCount: Int) { internal fun failedActivityBadgeText(count: Int): String = if (count > 9) "9+" else count.coerceAtLeast(0).toString() -internal fun activityCenterButtonContentDescription(failedCount: Int): String = - if (failedCount > 0) { - "Activity Center, $failedCount failed ${if (failedCount == 1) "activity needs" else "activities need"} attention" - } else { - "Activity Center" - } - @Composable private fun NeedsAttentionSection(items: List) { Card(Modifier.fillMaxWidth()) { Column { Text( - "Needs Attention", + stringResource(R.string.dashboard_needs_attention), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 14.dp, top = 12.dp, end = 14.dp, bottom = 4.dp), + modifier = Modifier + .padding(start = 14.dp, top = 12.dp, end = 14.dp, bottom = 4.dp) + .semantics { heading() }, ) items.forEachIndexed { index, item -> NeedsAttentionRow(item = item) @@ -820,14 +864,15 @@ private fun NeedsAttentionSection(items: List) { @Composable private fun NeedsAttentionRow(item: NeedsAttentionItem) { - val tint = when (item.severity) { + val tint = accessibleOnSurface(when (item.severity) { NeedsAttentionSeverity.Critical -> ArcaneRed NeedsAttentionSeverity.Warning -> ArcaneOrange - } + }) Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = item.action) + .semantics { role = Role.Button } .padding(horizontal = 14.dp, vertical = 9.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -875,13 +920,13 @@ private fun DashboardStreamFailedBanner(onRetry: () -> Unit) { modifier = Modifier.size(18.dp), ) Text( - "Live counts paused", + stringResource(R.string.dashboard_live_counts_paused), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) TextButton(onClick = onRetry) { - Text("Retry") + Text(stringResource(R.string.action_retry)) } } } @@ -1207,10 +1252,13 @@ private fun SystemPruneSheet( Scaffold( topBar = { TopAppBar( - title = { Text("System Prune") }, + title = { Text(stringResource(R.string.action_system_prune)) }, navigationIcon = { IconButton(onClick = onDismiss) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Cancel") + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_cancel), + ) } }, actions = { diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/EnvironmentDashboardCard.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/EnvironmentDashboardCard.kt index 0c904cf..5dc7c0d 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/EnvironmentDashboardCard.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/EnvironmentDashboardCard.kt @@ -1,5 +1,6 @@ package app.getarcane.android.ui.screens +import androidx.annotation.StringRes import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi @@ -45,9 +46,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.getarcane.android.R import app.getarcane.android.core.DashboardActionItem import app.getarcane.android.core.DashboardActionItemKind import app.getarcane.android.core.DashboardActionItemSeverity @@ -62,6 +70,7 @@ import app.getarcane.android.ui.theme.ArcaneGreen import app.getarcane.android.ui.theme.ArcaneOrange import app.getarcane.android.ui.theme.ArcanePurple import app.getarcane.android.ui.theme.ArcaneTeal +import app.getarcane.android.ui.theme.accessibleOnSurface import app.getarcane.sdk.EnvironmentId import app.getarcane.sdk.models.base.intValue import app.getarcane.sdk.models.system.DockerInfo @@ -69,12 +78,12 @@ import app.getarcane.sdk.models.version.VersionInfo import java.util.Locale import kotlin.math.roundToInt -enum class EnvironmentCardAction(val label: String) { - UseEnvironment("Use Environment"), - ViewSystemDetails("View System Details"), - Sync("Sync"), - UpgradeArcane("Upgrade Arcane"), - SystemPrune("System Prune"), +enum class EnvironmentCardAction(@get:StringRes val labelRes: Int) { + UseEnvironment(R.string.environment_action_use), + ViewSystemDetails(R.string.environment_action_view_details), + Sync(R.string.environment_action_sync), + UpgradeArcane(R.string.environment_action_upgrade), + SystemPrune(R.string.action_system_prune), } fun environmentCardActions(canPrune: Boolean): List = @@ -87,6 +96,30 @@ fun environmentCardActions(canPrune: Boolean): List = } } +internal fun visibleEnvironmentCardActions( + actions: List, + isActive: Boolean, + canUpgrade: Boolean, +): List = + actions.filterNot { + it == EnvironmentCardAction.UpgradeArcane || + (it == EnvironmentCardAction.UseEnvironment && isActive) + } + listOfNotNull(EnvironmentCardAction.UpgradeArcane.takeIf { canUpgrade }) + +internal fun isEnvironmentCardActionEnabled( + action: EnvironmentCardAction, + environmentStatus: String?, + syncing: Boolean, +): Boolean = when (action) { + EnvironmentCardAction.Sync -> !syncing + EnvironmentCardAction.UpgradeArcane, + EnvironmentCardAction.SystemPrune, + -> environmentStatus.equals("online", ignoreCase = true) + EnvironmentCardAction.UseEnvironment, + EnvironmentCardAction.ViewSystemDetails, + -> true +} + /** Per-environment dashboard card with live CPU/Mem/Disk rings. Port of iOS `EnvironmentDashboardCard`. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -97,6 +130,7 @@ fun EnvironmentDashboardCard( statsSeries: DashboardStatsSeries?, versionInfo: VersionInfo? = null, refreshToken: Int = 0, + syncing: Boolean = false, onSelect: () -> Unit, actions: List = environmentCardActions(canPrune = false), onAction: (EnvironmentCardAction) -> Unit = {}, @@ -107,6 +141,7 @@ fun EnvironmentDashboardCard( val currentUser = manager.currentUser val envId = EnvironmentId(env.id) val isActive = manager.activeEnvironmentId.rawValue == env.id + val activeStateDescription = stringResource(R.string.state_active) var dockerInfo by remember(env.id) { mutableStateOf(null) } var upgradeAvailability by remember(env.id, session) { @@ -133,15 +168,15 @@ fun EnvironmentDashboardCard( if (manager.isCurrent(captured)) upgradeAvailability = availability } - val visibleActions = remember(actions, upgradeAvailability) { - actions.filterNot { it == EnvironmentCardAction.UpgradeArcane } + - listOfNotNull(EnvironmentCardAction.UpgradeArcane.takeIf { upgradeAvailability.canUpgrade }) + val visibleActions = remember(actions, upgradeAvailability, isActive) { + visibleEnvironmentCardActions(actions, isActive, upgradeAvailability.canUpgrade) } val stats = statsSeries?.latest val cpuPct = stats?.cpuUsage val memPct = stats?.let { if (it.memoryTotal > 0) it.memoryUsage.toDouble() / it.memoryTotal * 100.0 else null } val diskPct = diskPercent(stats) + val diskTint = accessibleOnSurface(ArcaneTeal) Card( modifier = Modifier @@ -149,7 +184,12 @@ fun EnvironmentDashboardCard( .combinedClickable( onClick = onSelect, onLongClick = { showMenu = true }, - ), + ) + .semantics { + role = Role.Button + selected = isActive + if (isActive) stateDescription = activeStateDescription + }, shape = RoundedCornerShape(20.dp), border = if (isActive) BorderStroke(2.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)) else null, colors = CardDefaults.cardColors(), @@ -171,12 +211,16 @@ fun EnvironmentDashboardCard( Text(env.name ?: env.id, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) } dockerInfo?.serverVersion?.let { - Text("Docker $it", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + stringResource(R.string.environment_docker_version, it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } Box { IconButton(onClick = { showMenu = true }) { - Icon(Icons.Filled.MoreVert, contentDescription = "Environment actions") + Icon(Icons.Filled.MoreVert, contentDescription = stringResource(R.string.a11y_environment_actions)) } DropdownMenu( expanded = showMenu, @@ -184,8 +228,9 @@ fun EnvironmentDashboardCard( ) { visibleActions.forEach { action -> DropdownMenuItem( - text = { Text(action.label) }, + text = { Text(stringResource(action.labelRes)) }, leadingIcon = { Icon(action.icon, contentDescription = null) }, + enabled = isEnvironmentCardActionEnabled(action, env.status, syncing), onClick = { showMenu = false onAction(action) @@ -198,14 +243,14 @@ fun EnvironmentDashboardCard( Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { SparklineMetric( - title = "CPU", + title = stringResource(R.string.metric_cpu), value = pctShort(cpuPct), series = statsSeries?.cpu.orEmpty(), tint = ArcaneBlue, modifier = Modifier.weight(1f), ) SparklineMetric( - title = "Memory", + title = stringResource(R.string.metric_memory), value = pctShort(memPct), series = statsSeries?.memory.orEmpty(), tint = ArcanePurple, @@ -215,13 +260,17 @@ fun EnvironmentDashboardCard( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text("Disk", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(pctShort(diskPct), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = ArcaneTeal) + Text( + stringResource(R.string.metric_disk), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(pctShort(diskPct), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = diskTint) } LinearProgressIndicator( progress = { ((diskPct ?: 0.0) / 100.0).coerceIn(0.0, 1.0).toFloat() }, modifier = Modifier.fillMaxWidth(), - color = ArcaneTeal, + color = diskTint, trackColor = MaterialTheme.colorScheme.surfaceVariant, ) statsSeries?.error?.let { diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/UpdateAllEnvironmentsDialog.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/UpdateAllEnvironmentsDialog.kt index 6135a08..8a27dc9 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/UpdateAllEnvironmentsDialog.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/UpdateAllEnvironmentsDialog.kt @@ -21,41 +21,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import app.getarcane.android.R import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.LocalOperationStore import app.getarcane.android.core.OperationStartResult import app.getarcane.android.ui.components.ContentUnavailable import app.getarcane.android.ui.operations.OperationDetail -import app.getarcane.sdk.models.system.EnvironmentUpdateJob -import app.getarcane.sdk.models.system.EnvironmentUpdateJobStatus -import app.getarcane.sdk.models.system.EnvironmentUpdateResultStatus import app.getarcane.sdk.models.user.isGlobalAdmin internal fun shouldShowUpdateAllAction(isAdmin: Boolean): Boolean = isAdmin -internal fun updateAllLastRunSummary(job: EnvironmentUpdateJob): String { - if (job.status == EnvironmentUpdateJobStatus.FAILED) return job.error ?: "Failed" - val results = job.results.orEmpty() - val updated = results.count { it.status == EnvironmentUpdateResultStatus.UPDATED || it.status == EnvironmentUpdateResultStatus.TRIGGERED } - val failed = results.count { it.status == EnvironmentUpdateResultStatus.FAILED } - val skipped = results.count { it.status == EnvironmentUpdateResultStatus.SKIPPED_OFFLINE } - return buildList { - add("$updated updated") - if (failed > 0) add("$failed failed") - if (skipped > 0) add("$skipped skipped") - job.managerTargetVersion?.takeIf { it.isNotBlank() && ':' !in it && it.length <= 20 }?.let(::add) - }.joinToString(" · ") -} - -internal fun updateAllFinishedMessage(job: EnvironmentUpdateJob, note: String?): String = - note ?: when { - job.status == EnvironmentUpdateJobStatus.COMPLETED && - job.results.orEmpty().none { it.status == EnvironmentUpdateResultStatus.FAILED } -> "All environments updated" - job.status == EnvironmentUpdateJobStatus.FAILED -> job.error ?: "Fleet update failed" - else -> updateAllLastRunSummary(job) - } - /** Fleet update surface backed by the one app-scoped operation owner and SDK job contract. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -75,13 +53,21 @@ fun UpdateAllEnvironmentsDialog( Scaffold( topBar = { TopAppBar( - title = { Text("Update All") }, - actions = { IconButton(onClick = onDismiss) { Icon(Icons.Filled.Close, "Close") } }, + title = { Text(stringResource(R.string.update_all_title)) }, + actions = { + IconButton(onClick = onDismiss) { + Icon(Icons.Filled.Close, stringResource(R.string.a11y_close)) + } + }, ) }, ) { padding -> when { - !isAdmin -> ContentUnavailable("Admins Only", Icons.Filled.Lock, "Updating all environments requires an administrator account.") + !isAdmin -> ContentUnavailable( + stringResource(R.string.update_all_admin_only_title), + Icons.Filled.Lock, + stringResource(R.string.update_all_admin_only_message), + ) record != null -> Box(Modifier.fillMaxSize().padding(padding)) { OperationDetail( record = record, @@ -95,7 +81,13 @@ fun UpdateAllEnvironmentsDialog( } else -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { androidx.compose.foundation.layout.Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(startError ?: "Update all $environmentCount environments to the latest Arcane release?") + Text( + startError ?: pluralStringResource( + R.plurals.update_all_confirmation, + environmentCount, + environmentCount, + ), + ) Button( onClick = { when (val result = store.startFleetUpdate()) { @@ -105,7 +97,7 @@ fun UpdateAllEnvironmentsDialog( } }, modifier = Modifier.padding(top = 16.dp), - ) { Text("Update All") } + ) { Text(stringResource(R.string.update_all_action)) } } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt index c466c6f..4ab058c 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerActionPolicy.kt @@ -1,20 +1,25 @@ package app.getarcane.android.ui.screens.containers +import androidx.annotation.StringRes +import app.getarcane.android.R + internal enum class ContainerDetailAction( val permission: String, - val title: String, + @get:StringRes val titleRes: Int, + @get:StringRes val successMessageRes: Int? = null, + @get:StringRes val confirmationMessageRes: Int? = null, ) { - Start("containers:start", "Start"), - Stop("containers:stop", "Stop"), - Restart("containers:restart", "Restart"), - Pause("containers:pause", "Pause"), - Unpause("containers:pause", "Unpause"), - Kill("containers:kill", "Kill"), - Redeploy("containers:redeploy", "Redeploy"), - Delete("containers:delete", "Delete"), - Inspect("containers:read", "Inspect"), - Logs("containers:logs", "Logs"), - Terminal("containers:exec", "Terminal"), + Start("containers:start", R.string.container_action_start, R.string.container_started), + Stop("containers:stop", R.string.container_action_stop, R.string.container_stopped, R.string.confirm_container_stop_message), + Restart("containers:restart", R.string.container_action_restart, R.string.container_restarted, R.string.confirm_container_restart_message), + Pause("containers:pause", R.string.container_action_pause, R.string.container_paused, R.string.confirm_container_pause_message), + Unpause("containers:pause", R.string.container_action_unpause, R.string.container_unpaused), + Kill("containers:kill", R.string.container_action_kill, R.string.container_killed, R.string.confirm_container_kill_message), + Redeploy("containers:redeploy", R.string.container_action_redeploy, R.string.container_redeployed, R.string.confirm_container_redeploy_message), + Delete("containers:delete", R.string.container_action_delete, R.string.container_deleted, R.string.confirm_container_delete_message), + Inspect("containers:read", R.string.container_action_inspect), + Logs("containers:logs", R.string.container_action_logs), + Terminal("containers:exec", R.string.container_action_terminal), } internal fun availableContainerActions( @@ -45,36 +50,3 @@ internal fun availableContainerActions( } } } - -internal fun ContainerDetailAction.confirmationMessage(resourceName: String, environmentName: String): String? = - when (this) { - ContainerDetailAction.Stop -> - "Stop “$resourceName” in $environmentName? The container can be started again." - ContainerDetailAction.Restart -> - "Restart “$resourceName” in $environmentName? Active connections may be interrupted." - ContainerDetailAction.Pause -> - "Pause “$resourceName” in $environmentName? Its processes will stop running until unpaused." - ContainerDetailAction.Kill -> - "Force kill “$resourceName” in $environmentName with SIGKILL? The process cannot shut down cleanly." - ContainerDetailAction.Redeploy -> - "Redeploy “$resourceName” in $environmentName? Arcane will pull and recreate the container." - ContainerDetailAction.Delete -> - "Permanently delete “$resourceName” from $environmentName? Arcane will force removal if needed; this cannot be undone." - else -> null - } - -internal val ContainerDetailAction.successMessage: String - get() = when (this) { - ContainerDetailAction.Start -> "Container started." - ContainerDetailAction.Stop -> "Container stopped." - ContainerDetailAction.Restart -> "Container restarted." - ContainerDetailAction.Pause -> "Container paused." - ContainerDetailAction.Unpause -> "Container unpaused." - ContainerDetailAction.Kill -> "Container killed." - ContainerDetailAction.Redeploy -> "Container redeployed." - ContainerDetailAction.Delete -> "Container deleted." - ContainerDetailAction.Inspect, - ContainerDetailAction.Logs, - ContainerDetailAction.Terminal, - -> "" - } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt index a8bfcc8..1bb4ee1 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerDetailScreen.kt @@ -72,11 +72,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.R import app.getarcane.android.core.LocalOperationStore import app.getarcane.android.core.OperationStartResult import app.getarcane.android.core.ReadResource @@ -87,6 +89,7 @@ import app.getarcane.android.core.formatBytes import app.getarcane.android.core.supportsContainerReliabilityActions import app.getarcane.android.ui.components.CachedAsyncImage import app.getarcane.android.ui.components.ErrorBanner +import app.getarcane.android.ui.components.DestructiveConfirmationDialog import app.getarcane.android.ui.components.StatusBadge import app.getarcane.android.ui.theme.ArcaneBlue import app.getarcane.android.ui.theme.ArcaneGreen @@ -223,7 +226,9 @@ fun ContainerDetailScreen( else -> return@launch } manager.invalidateReadCache(envId, ReadResource.CONTAINERS) - Toast.makeText(context, action.successMessage, Toast.LENGTH_SHORT).show() + action.successMessageRes?.let { messageRes -> + Toast.makeText(context, context.getString(messageRes), Toast.LENGTH_SHORT).show() + } if (action == ContainerDetailAction.Delete) onBack() else refreshKey++ } catch (e: CancellationException) { throw e @@ -238,7 +243,7 @@ fun ContainerDetailScreen( fun request(action: ContainerDetailAction) { if (action !in availableActions) return - if (action.confirmationMessage(title, manager.activeEnvironmentName) != null) { + if (action.confirmationMessageRes != null) { pendingAction = action } else { perform(action) @@ -336,17 +341,20 @@ fun ContainerDetailScreen( } pendingAction?.let { action -> - AlertDialog( - onDismissRequest = { pendingAction = null }, - title = { Text("${action.title} Container?") }, - text = { Text(requireNotNull(action.confirmationMessage(title, manager.activeEnvironmentName))) }, - confirmButton = { - TextButton(onClick = { - pendingAction = null - perform(action) - }) { Text(action.title, color = if (action in setOf(ContainerDetailAction.Kill, ContainerDetailAction.Delete)) ArcaneRed else MaterialTheme.colorScheme.primary) } + DestructiveConfirmationDialog( + title = stringResource(R.string.confirm_container_action_title, stringResource(action.titleRes)), + message = stringResource( + requireNotNull(action.confirmationMessageRes), + title, + manager.activeEnvironmentName, + ), + confirmLabel = stringResource(action.titleRes), + destructive = action in setOf(ContainerDetailAction.Kill, ContainerDetailAction.Delete), + onConfirm = { + pendingAction = null + perform(action) }, - dismissButton = { TextButton(onClick = { pendingAction = null }) { Text("Cancel") } }, + onDismiss = { pendingAction = null }, ) } @@ -507,7 +515,7 @@ private fun ActionToolbar( ) toolbarActions.filter { it.first in availableActions }.forEach { (action, presentation) -> CircleActionButton( - action.title, + stringResource(action.titleRes), presentation.first, presentation.second, busy, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerStatsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerStatsScreen.kt index 5e919a6..a1c1cae 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerStatsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/containers/ContainerStatsScreen.kt @@ -405,7 +405,8 @@ private fun parseFrame(payload: ContainerStatsPayload, previous: StatsFrame?, no ) } -private fun percentString(value: Double?): String = value?.let { String.format("%.1f%%", it) } ?: "—" +private fun percentString(value: Double?): String = + value?.let { String.format(java.util.Locale.getDefault(), "%.1f%%", it) } ?: "—" private fun rateString(bytesPerSec: Double?): String { if (bytesPerSec == null || !bytesPerSec.isFinite()) return "—" diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt index 84d57bf..3e8da27 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/environments/EnvironmentListScreen.kt @@ -39,9 +39,15 @@ 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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import app.getarcane.android.R import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.core.Loadable import app.getarcane.android.core.ReadCachePolicy @@ -182,7 +188,7 @@ fun EnvironmentListScreen(onOpen: (String) -> Unit) { @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -private fun EnvironmentRow( +internal fun EnvironmentRow( env: Environment, isActive: Boolean, onClick: () -> Unit, @@ -195,6 +201,10 @@ private fun EnvironmentRow( Modifier .fillMaxWidth() .combinedClickable(onClick = onClick, onLongClick = { if (!isActive) menu = true }) + .semantics { + role = Role.Button + selected = isActive + } .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -209,7 +219,7 @@ private fun EnvironmentRow( Text(env.label, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) if (isActive) { Text( - "Active", + stringResource(R.string.state_active), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = Color.White, @@ -224,7 +234,11 @@ private fun EnvironmentRow( StatusBadge(env.status) } DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { - DropdownMenuItem(text = { Text("Set Active") }, onClick = { menu = false; onSetActive() }, leadingIcon = { Icon(Icons.Filled.CheckCircle, null) }) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_set_active)) }, + onClick = { menu = false; onSetActive() }, + leadingIcon = { Icon(Icons.Filled.CheckCircle, null) }, + ) } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/images/AllVulnerabilitiesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/images/AllVulnerabilitiesScreen.kt index 6b71193..3d100ac 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/images/AllVulnerabilitiesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/images/AllVulnerabilitiesScreen.kt @@ -347,7 +347,7 @@ private fun VulnerabilityWithImageRow(item: VulnerabilityWithImage, onClick: () } item.cvss?.preferredScore?.let { Text( - String.format("%.1f", it), + String.format(java.util.Locale.getDefault(), "%.1f", it), style = MaterialTheme.typography.labelMedium, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -387,7 +387,7 @@ private fun VulnerabilityWithImageDetailDialog( record.cvss?.preferredScore?.let { LabeledRow( "CVSS", - String.format("%.1f", it) + String.format(java.util.Locale.getDefault(), "%.1f", it) ) } record.publishedDate?.let { LabeledRow("Published", formatImageDate(it)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageVulnerabilitiesScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageVulnerabilitiesScreen.kt index a8c9d9d..b8afc0d 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageVulnerabilitiesScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/images/ImageVulnerabilitiesScreen.kt @@ -436,7 +436,7 @@ private fun VulnerabilityRow( } else { record.cvss?.preferredScore?.let { cvss -> Text( - String.format("%.1f", cvss), + String.format(java.util.Locale.getDefault(), "%.1f", cvss), style = MaterialTheme.typography.labelMedium, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -470,7 +470,7 @@ private fun VulnerabilityDetailDialog( record.cvss?.preferredScore?.let { LabeledRow( "CVSS", - String.format("%.1f", it) + String.format(java.util.Locale.getDefault(), "%.1f", it) ) } record.publishedDate?.let { LabeledRow("Published", formatImageDate(it)) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ComposeFileScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ComposeFileScreen.kt index 184e37f..2419ae0 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ComposeFileScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ComposeFileScreen.kt @@ -22,10 +22,10 @@ import androidx.compose.material.icons.filled.CreateNewFolder import androidx.compose.material.icons.filled.DataObject import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.DriveFileMove +import androidx.compose.material.icons.automirrored.filled.DriveFileMove import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.MoreVert @@ -305,7 +305,7 @@ fun ComposeFileScreen( DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) { DropdownMenuItem( text = { Text("New File") }, - leadingIcon = { Icon(Icons.Filled.InsertDriveFile, null) }, + leadingIcon = { Icon(Icons.AutoMirrored.Filled.InsertDriveFile, null) }, onClick = { addMenu = false; createKind = WorkspaceCreateKind.FILE }, ) DropdownMenuItem( @@ -652,7 +652,7 @@ private fun ProjectWorkspaceEditor( } if (entry.structuralEditable && canUpdateNow && structuralActionsAllowed) { IconButton(onClick = onRename) { Icon(Icons.Filled.Edit, "Rename") } - IconButton(onClick = onMove) { Icon(Icons.Filled.DriveFileMove, "Move") } + IconButton(onClick = onMove) { Icon(Icons.AutoMirrored.Filled.DriveFileMove, "Move") } IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "Delete") } } } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ProjectDetailScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ProjectDetailScreen.kt index de2bdb6..9e502b7 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ProjectDetailScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/projects/ProjectDetailScreen.kt @@ -54,12 +54,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import app.getarcane.android.core.LocalArcaneManager +import app.getarcane.android.R import app.getarcane.android.core.ReadResource import app.getarcane.android.core.Loadable import app.getarcane.android.core.ProjectDeployPreferenceScope @@ -347,20 +349,23 @@ internal fun ProjectDetailScreen( if (confirmDelete) { AlertDialog( onDismissRequest = { confirmDelete = false }, - title = { Text("Delete $title?") }, + title = { Text(stringResource(R.string.confirm_project_delete_title, title)) }, text = { - Text( - "Remove $title from ${manager.activeEnvironmentName}, or also remove its files from disk. " + - "This action cannot be undone.", - ) + Text(stringResource(R.string.confirm_project_delete_message, title, manager.activeEnvironmentName)) }, confirmButton = { Column { - TextButton(onClick = { runDelete(removeFiles = false) }) { Text("Delete") } - TextButton(onClick = { runDelete(removeFiles = true) }) { Text("Delete and Remove Files") } + TextButton(onClick = { runDelete(removeFiles = false) }) { + Text(stringResource(R.string.action_delete)) + } + TextButton(onClick = { runDelete(removeFiles = true) }) { + Text(stringResource(R.string.action_delete_files)) + } } }, - dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } }, + dismissButton = { + TextButton(onClick = { confirmDelete = false }) { Text(stringResource(R.string.action_cancel)) } + }, ) } diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PermissionResourceVisuals.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PermissionResourceVisuals.kt index ae4de48..040e8ef 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PermissionResourceVisuals.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/PermissionResourceVisuals.kt @@ -6,7 +6,7 @@ import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.CallMerge +import androidx.compose.material.icons.automirrored.filled.CallMerge import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Hub @@ -53,7 +53,7 @@ fun permissionResourceIcon(key: String): ImageVector = when (key) { "environments" -> Icons.Filled.Storage "registries" -> Icons.Filled.Cloud "templates" -> Icons.Filled.Description - "git-repositories" -> Icons.Filled.CallMerge + "git-repositories" -> Icons.AutoMirrored.Filled.CallMerge "gitops" -> Icons.Filled.Sync "webhooks" -> Icons.Filled.Webhook "system" -> Icons.Filled.Settings diff --git a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt index bad738f..a4f81e3 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/screens/settings/SettingsScreen.kt @@ -20,11 +20,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import app.getarcane.android.R import app.getarcane.android.core.LocalArcaneManager import app.getarcane.android.nav.AppTab import app.getarcane.android.nav.NavTabsStore @@ -284,10 +286,10 @@ private fun SettingsRoot(nav: NavHostController) { Scaffold( topBar = { TopAppBar( - title = { Text("Settings") }, + title = { Text(stringResource(R.string.nav_settings)) }, actions = { IconButton(onClick = { nav.navigate(SettingsRoutes.APP_SETTINGS) }) { - Icon(Icons.Filled.Settings, contentDescription = "App Settings") + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.a11y_app_settings)) } }, ) @@ -310,12 +312,12 @@ private fun SettingsRoot(nav: NavHostController) { val tabs = visibleTabs(section) if (tabs.isNotEmpty()) { item(key = "${section.name}-header") { - SettingsSectionHeader(section.title) + SettingsSectionHeader(stringResource(section.titleRes)) } tabs.forEach { tab -> item(key = tab.id) { SettingsRow( - title = tab.title, + title = stringResource(tab.titleRes), icon = tab.icon, iconColor = tab.color, onClick = { nav.navigate(tab.id) }, diff --git a/app/src/main/kotlin/app/getarcane/android/ui/theme/Color.kt b/app/src/main/kotlin/app/getarcane/android/ui/theme/Color.kt index 07eeca0..e26218b 100644 --- a/app/src/main/kotlin/app/getarcane/android/ui/theme/Color.kt +++ b/app/src/main/kotlin/app/getarcane/android/ui/theme/Color.kt @@ -1,6 +1,9 @@ package app.getarcane.android.ui.theme import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable // iOS system palette — used as accent options and semantic/status colors, // matching AppearanceSettingsView.AccentColorOption in the iOS app. @@ -22,3 +25,18 @@ val StatusRunning = ArcaneGreen val StatusStopped = ArcaneRed val StatusPaused = ArcaneOrange val StatusUnknown = ArcaneGray + +/** Keeps semantic/accent foregrounds readable on the active Material surface. */ +@Composable +fun accessibleOnSurface(color: Color): Color { + if (MaterialTheme.colorScheme.surface.luminance() <= 0.5f) return color + return when (color) { + ArcaneGreen -> Color(0xFF146C2E) + ArcaneRed -> Color(0xFFB3261E) + ArcaneOrange, ArcaneYellow -> Color(0xFF765600) + ArcaneTeal, ArcaneMint, ArcaneCyan -> Color(0xFF006875) + ArcaneBlue -> Color(0xFF005AC1) + ArcanePurple, ArcanePink -> Color(0xFF74308A) + else -> color + } +} diff --git a/app/src/main/kotlin/app/getarcane/android/widget/FleetStatusWidget.kt b/app/src/main/kotlin/app/getarcane/android/widget/FleetStatusWidget.kt index 4f223e9..74b16e2 100644 --- a/app/src/main/kotlin/app/getarcane/android/widget/FleetStatusWidget.kt +++ b/app/src/main/kotlin/app/getarcane/android/widget/FleetStatusWidget.kt @@ -2,7 +2,6 @@ package app.getarcane.android.widget import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize @@ -32,6 +31,7 @@ import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider +import androidx.core.net.toUri import app.getarcane.android.BuildConfig import app.getarcane.android.MainActivity import app.getarcane.android.core.StatusSnapshotStore @@ -96,7 +96,7 @@ private fun FleetStatusContent(context: Context, model: FleetWidgetModel) { val size = LocalSize.current val action = model.routeUri?.let { uri -> actionStartActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(uri), context, MainActivity::class.java), + Intent(Intent.ACTION_VIEW, uri.toUri(), context, MainActivity::class.java), ) } val modifier = GlanceModifier diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 413f835..534c1df 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,12 +1,5 @@ - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF #FF070911 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ca62da..d845c08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,8 +1,230 @@ + Arcane Mobile + Arcane + + + Restoring your Arcane session + Setting things up for you… + Connect to your Arcane server + Server URL + https://arcane.example.com + For a local server, include the scheme — for example, https://192.168.1.50:3000 + Server + Username + Password + Connect + Sign in with a passkey + + Continue with %1$s + OIDC + Hide password sign in + Sign in with username and password + Sign in + Cancel passkey request + Change server + Two-factor authentication + Use a registered passkey or one of your recovery codes. + Continue with passkey + Passkey verification is unavailable for this server connection. Use a recovery code. + Recovery code + Use recovery code + Back to sign in + Starting demo… + Try the demo + This usually takes about 30 seconds. + Temporary instance for about 10 minutes. No account needed. + + + Management + Resources + Swarm + Administration + Dashboard + Projects + Container Registries + Registries + Template Registries + Templates + Git Repositories + Git Repos + GitOps + Containers + Images + Builds + Updates + Networks + Ports + Volumes + Jobs + Activities + Activity + Events + Global Variables + Variables + Users + API Keys + Notifications + Webhooks + Authentication + Auth + Roles + OIDC Role Mappings + OIDC Roles + System Settings + System + Settings + More + All destinations + + Customize %1$s tab + Replace %1$s + Reset + + + Cancel + Delete + Delete and remove files + Dismiss + Retry + Check again + View + Activity Center + Back to operations + Set active + Open + Unpin + System Prune + Active + Start + Stop + Restart + Pause + Unpause + Kill + Redeploy + Delete + Inspect + Logs + Terminal + + %1$s container? + + Stop “%1$s” in %2$s? The container can be started again. + Restart “%1$s” in %2$s? Active connections may be interrupted. + Pause “%1$s” in %2$s? Its processes will stop running until unpaused. + Force kill “%1$s” in %2$s with SIGKILL? The process cannot shut down cleanly. + Redeploy “%1$s” in %2$s? Arcane will pull and recreate the container. + Permanently delete “%1$s” from %2$s? Arcane will force removal if needed; this cannot be undone. + Container started. + Container stopped. + Container restarted. + Container paused. + Container unpaused. + Container killed. + Container redeployed. + Container deleted. + Delete %1$s? + + Remove %1$s from %2$s, or also remove its files from disk. This action cannot be undone. + + + %1$d operation in progress + %1$d operations in progress + + Reconnecting to server activity + Operations + No recent operations + Output + Queued + Starting + Running + Reconnecting + Cancellation requested + Completed + Failed + Cancelled + Interrupted + Outcome unknown + Cleared + + + Dismiss + Close + App settings + Environment actions + Activity Center + Pinned item actions + + %1$s, %2$s + + Activity Center, %1$d failed activity needs attention + Activity Center, %1$d failed activities need attention + + Needs Attention + Environments + Live counts paused + Running + Stopped + In use + Unused + Stop container + Start container + Stop project + Deploy project + + Syncing %1$s… + Synced %1$s + Use Environment + View System Details + Sync + Upgrade Arcane + + Docker %1$s + CPU + Memory + Disk + Update All + Update All + Admins Only + Updating all environments requires an administrator account. + + Update %1$d environment to the latest Arcane release? + Update all %1$d environments to the latest Arcane release? + Operations Progress for operations started in Arcane Cancel + Arcane operations + + %1$d active operation + %1$d active operations + + + %1$d recent operation + %1$d recent operations + + Arcane operation in progress + Arcane operation finished + + %1$s · %2$s + Project deploy + Project redeploy + Project image pull + Project build + Image pull + Container redeploy + Updater run + Fleet update + Arcane operation + %1$d%% complete + Completed with issues + Finished Dashboard Containers Projects diff --git a/app/src/test/java/app/getarcane/android/core/OperationStorePolicyTest.kt b/app/src/test/java/app/getarcane/android/core/OperationStorePolicyTest.kt index ef6c3b9..137ba66 100644 --- a/app/src/test/java/app/getarcane/android/core/OperationStorePolicyTest.kt +++ b/app/src/test/java/app/getarcane/android/core/OperationStorePolicyTest.kt @@ -1,8 +1,9 @@ package app.getarcane.android.core +import app.getarcane.android.R +import app.getarcane.sdk.errors.ArcaneError import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject -import app.getarcane.sdk.errors.ArcaneError import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -80,7 +81,7 @@ class OperationStorePolicyTest { } @Test - fun `notification text is closed vocabulary and omits targets errors and server URLs`() { + fun `notification title is a closed resource mapping independent of sensitive record fields`() { val sensitive = record().copy( kind = OperationKind.IMAGE_PULL, state = OperationState.FAILURE, @@ -88,11 +89,11 @@ class OperationStorePolicyTest { detailMessage = "https://server.example token=secret", presentationCode = OperationPresentationCode.COMPLETED_WITH_ISSUES, ) - val projected = sensitive.kind.notificationTitle() + " " + sensitive.notificationStateText() - assertEquals("Image pull Completed with issues", projected) - assertFalse(projected.contains("private.registry")) - assertFalse(projected.contains("server.example")) - assertFalse(projected.contains("secret")) + assertEquals(R.string.operation_notification_image_pull, sensitive.kind.notificationTitleRes()) + assertEquals( + sensitive.kind.notificationTitleRes(), + sensitive.copy(targetName = "other", detailMessage = "other").kind.notificationTitleRes(), + ) } @Test diff --git a/app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt b/app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt index bda3cbd..361c104 100644 --- a/app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt +++ b/app/src/test/java/app/getarcane/android/core/PrefsAppearanceTest.kt @@ -5,11 +5,13 @@ import androidx.datastore.preferences.core.edit import java.io.File import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -21,6 +23,20 @@ class PrefsAppearanceTest { @get:Rule val temp = TemporaryFolder() + private val testScopes = mutableListOf() + + private fun testScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.IO).also(testScopes::add) + + @After + fun cancelTestScopes() = runBlocking { + testScopes.asReversed().forEach { scope -> + val job = scope.coroutineContext[Job] + scope.cancel() + job?.join() + } + } + @Test fun persistedValueMappingFallsBackToAuto() { assertEquals(AppThemeMode.LIGHT, AppThemeMode.fromPersistedValue("light")) @@ -40,7 +56,7 @@ class PrefsAppearanceTest { @Test fun allThemeModesPersistWithoutChangingAccent() = runBlocking { - val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val dataStoreScope = testScope() val file = File(temp.root, "arcane_prefs.preferences_pb") val dataStore = PreferenceDataStoreFactory.create(scope = dataStoreScope) { file } val prefs = Prefs(dataStore) @@ -55,12 +71,11 @@ class PrefsAppearanceTest { assertEquals("#AF52DE", anotherPrefsOwner.accentHex.first()) } - dataStoreScope.cancel() } @Test fun invalidStoredModeFallsBackWithoutChangingAccent() = runBlocking { - val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val dataStoreScope = testScope() val file = File(temp.root, "invalid_arcane_prefs.preferences_pb") val dataStore = PreferenceDataStoreFactory.create(scope = dataStoreScope) { file } val prefs = Prefs(dataStore) @@ -72,13 +87,12 @@ class PrefsAppearanceTest { assertEquals(AppThemeMode.AUTO, prefs.themeMode.first()) assertEquals("#34C759", prefs.accentHex.first()) - dataStoreScope.cancel() } @Test fun appOwnedAppearanceStatePersistsAfterSetterReturns() = runBlocking { - val dataStoreScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val appearanceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val dataStoreScope = testScope() + val appearanceScope = testScope() val file = File(temp.root, "owned_arcane_prefs.preferences_pb") val dataStore = PreferenceDataStoreFactory.create(scope = dataStoreScope) { file } val appearancePreferences = AppearancePreferences(Prefs(dataStore), appearanceScope) @@ -95,7 +109,5 @@ class PrefsAppearanceTest { assertEquals(AppThemeMode.LIGHT, appearancePreferences.themeMode.first { it == AppThemeMode.LIGHT }) assertEquals("#5856D6", appearancePreferences.accentHex.first { it == "#5856D6" }) - appearanceScope.cancel() - dataStoreScope.cancel() } } diff --git a/app/src/test/java/app/getarcane/android/ui/screens/DashboardActivityBadgeTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/DashboardActivityBadgeTest.kt index 076b2fc..7a1e294 100644 --- a/app/src/test/java/app/getarcane/android/ui/screens/DashboardActivityBadgeTest.kt +++ b/app/src/test/java/app/getarcane/android/ui/screens/DashboardActivityBadgeTest.kt @@ -17,16 +17,4 @@ class DashboardActivityBadgeTest { assertEquals("9+", failedActivityBadgeText(42)) } - @Test - fun activityCenterButtonDescriptionIncludesFailedCountOnlyWhenPresent() { - assertEquals("Activity Center", activityCenterButtonContentDescription(0)) - assertEquals( - "Activity Center, 1 failed activity needs attention", - activityCenterButtonContentDescription(1), - ) - assertEquals( - "Activity Center, 3 failed activities need attention", - activityCenterButtonContentDescription(3), - ) - } } diff --git a/app/src/test/java/app/getarcane/android/ui/screens/EnvironmentCardActionsTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/EnvironmentCardActionsTest.kt index c5083d9..f106da0 100644 --- a/app/src/test/java/app/getarcane/android/ui/screens/EnvironmentCardActionsTest.kt +++ b/app/src/test/java/app/getarcane/android/ui/screens/EnvironmentCardActionsTest.kt @@ -37,7 +37,7 @@ class EnvironmentCardActionsTest { actions, ) assertFalse(actions.contains(EnvironmentCardAction.SystemPrune)) - assertFalse(actions.map { it.label }.contains("Upgrade Arcane")) + assertFalse(actions.contains(EnvironmentCardAction.UpgradeArcane)) } @Test @@ -54,6 +54,32 @@ class EnvironmentCardActionsTest { actions, ) assertTrue(actions.contains(EnvironmentCardAction.SystemPrune)) - assertFalse(actions.map { it.label }.contains("Upgrade Arcane")) + assertFalse(actions.contains(EnvironmentCardAction.UpgradeArcane)) + } + + @Test + fun activeCardHidesUseActionAndUpgradeRequiresPositiveAvailability() { + val actions = environmentCardActions(canPrune = true) + + val active = visibleEnvironmentCardActions(actions, isActive = true, canUpgrade = false) + assertFalse(active.contains(EnvironmentCardAction.UseEnvironment)) + assertFalse(active.contains(EnvironmentCardAction.UpgradeArcane)) + + val remote = visibleEnvironmentCardActions(actions, isActive = false, canUpgrade = true) + assertTrue(remote.contains(EnvironmentCardAction.UseEnvironment)) + assertTrue(remote.contains(EnvironmentCardAction.UpgradeArcane)) + assertEquals(EnvironmentCardAction.SystemPrune, remote[remote.lastIndex - 1]) + assertEquals(EnvironmentCardAction.UpgradeArcane, remote.last()) + } + + @Test + fun offlineCardsKeepRecoveryAndReadActionsButDisableMutations() { + assertTrue(isEnvironmentCardActionEnabled(EnvironmentCardAction.UseEnvironment, "offline", syncing = false)) + assertTrue(isEnvironmentCardActionEnabled(EnvironmentCardAction.ViewSystemDetails, "offline", syncing = false)) + assertTrue(isEnvironmentCardActionEnabled(EnvironmentCardAction.Sync, "offline", syncing = false)) + assertFalse(isEnvironmentCardActionEnabled(EnvironmentCardAction.Sync, "offline", syncing = true)) + assertFalse(isEnvironmentCardActionEnabled(EnvironmentCardAction.UpgradeArcane, "offline", syncing = false)) + assertFalse(isEnvironmentCardActionEnabled(EnvironmentCardAction.SystemPrune, "offline", syncing = false)) + assertTrue(isEnvironmentCardActionEnabled(EnvironmentCardAction.SystemPrune, "online", syncing = false)) } } diff --git a/app/src/test/java/app/getarcane/android/ui/screens/UpdateAllEnvironmentsTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/UpdateAllEnvironmentsTest.kt index 8ca25df..facdc0c 100644 --- a/app/src/test/java/app/getarcane/android/ui/screens/UpdateAllEnvironmentsTest.kt +++ b/app/src/test/java/app/getarcane/android/ui/screens/UpdateAllEnvironmentsTest.kt @@ -4,6 +4,8 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import app.getarcane.android.core.FleetUpdateSummary +import app.getarcane.android.core.fleetUpdateSummary import app.getarcane.sdk.models.system.EnvironmentUpdateJob import app.getarcane.sdk.models.system.EnvironmentUpdateJobStatus import app.getarcane.sdk.models.system.EnvironmentUpdateResult @@ -31,7 +33,10 @@ class UpdateAllEnvironmentsTest { ), ) - assertEquals("2 updated · 1 failed · 1 skipped · 1.2.3", updateAllLastRunSummary(job)) + assertEquals( + FleetUpdateSummary(2, 1, 1, 1, 0, "1.2.3"), + fleetUpdateSummary(job), + ) } @Test @@ -45,11 +50,11 @@ class UpdateAllEnvironmentsTest { ), ) - assertEquals("1 updated", updateAllLastRunSummary(job)) + assertEquals(FleetUpdateSummary(1, 0, 0, 0, 0, null), fleetUpdateSummary(job)) } @Test - fun finishedMessagePrefersConnectionNote() { + fun pendingRestartRemainsPendingInTheFleetSummary() { val job = EnvironmentUpdateJob( id = "job-3", status = EnvironmentUpdateJobStatus.PENDING_RESTART, @@ -58,9 +63,44 @@ class UpdateAllEnvironmentsTest { ), ) - assertEquals( - "The Arcane manager is restarting. Check back in a minute.", - updateAllFinishedMessage(job, "The Arcane manager is restarting. Check back in a minute."), + assertEquals(FleetUpdateSummary(0, 0, 0, 0, 1, null), fleetUpdateSummary(job)) + } + + @Test + fun resultSummaryAttributesMoreThanTwentyEnvironmentsExactlyOnce() { + val results = (0 until 26).map { index -> + EnvironmentUpdateResult( + environmentId = index.toString(), + environmentName = "Environment $index", + status = when (index % 5) { + 0 -> EnvironmentUpdateResultStatus.UPDATED + 1 -> EnvironmentUpdateResultStatus.FAILED + 2 -> EnvironmentUpdateResultStatus.SKIPPED_OFFLINE + 3 -> EnvironmentUpdateResultStatus.SKIPPED_UP_TO_DATE + else -> EnvironmentUpdateResultStatus.PENDING + }, + ) + } + val job = EnvironmentUpdateJob( + id = "job-26", + status = EnvironmentUpdateJobStatus.COMPLETED, + results = results, + ) + + assertEquals(FleetUpdateSummary(6, 5, 5, 5, 5, null), fleetUpdateSummary(job)) + assertEquals(26, results.map { it.environmentId }.distinct().size) + } + + @Test + fun completedUpToDateFleetIsNotReportedAsUpdated() { + val job = EnvironmentUpdateJob( + id = "job-current", + status = EnvironmentUpdateJobStatus.COMPLETED, + results = listOf( + EnvironmentUpdateResult("0", "Manager", EnvironmentUpdateResultStatus.SKIPPED_UP_TO_DATE), + ), ) + + assertEquals(FleetUpdateSummary(0, 0, 0, 1, 0, null), fleetUpdateSummary(job)) } } diff --git a/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerActionPolicyTest.kt b/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerActionPolicyTest.kt index 21404e3..ccd6a35 100644 --- a/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerActionPolicyTest.kt +++ b/app/src/test/java/app/getarcane/android/ui/screens/containers/ContainerActionPolicyTest.kt @@ -2,6 +2,8 @@ package app.getarcane.android.ui.screens.containers import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -46,14 +48,8 @@ class ContainerActionPolicyTest { @Test fun destructiveConfirmationsIdentifyResourceAndEnvironment() { - val message = ContainerDetailAction.Kill.confirmationMessage("worker", "Local Docker") - - assertTrue(message!!.contains("worker")) - assertTrue(message.contains("Local Docker")) - assertTrue(message.contains("SIGKILL")) - - val deleteMessage = ContainerDetailAction.Delete.confirmationMessage("worker", "Local Docker")!! - assertTrue(deleteMessage.contains("force removal")) - assertTrue(deleteMessage.contains("cannot be undone")) + assertNotNull(ContainerDetailAction.Kill.confirmationMessageRes) + assertNotNull(ContainerDetailAction.Delete.confirmationMessageRes) + assertNull(ContainerDetailAction.Start.confirmationMessageRes) } } diff --git a/docs/accessibility-and-localization.md b/docs/accessibility-and-localization.md new file mode 100644 index 0000000..4b3ec7a --- /dev/null +++ b/docs/accessibility-and-localization.md @@ -0,0 +1,92 @@ +# Accessibility and localization foundation + +Arcane Android treats accessibility information as operational state, not decoration. Resource, +environment, and server names remain user/server data; UI instructions, action labels, state words, +and accessibility descriptions are translatable resources. + +## String-resource conventions + +- Use a stable, surface-first name such as `auth_sign_in`, `nav_dashboard`, + `confirm_delete_container_title`, or `operation_status_running`. Shared actions use the + `action_` prefix and accessibility-only descriptions use `a11y_`. +- Put complete sentences or complete grammatical phrases in one resource. Do not concatenate a + translated label with punctuation, counts, state words, or another translated fragment. +- Use positional `%1$s`/`%2$d` arguments for formatted text and escape literal percent signs. Add an + XML translator comment when an argument's meaning is not obvious. +- Use `` whenever grammar changes with quantity. Counts exposed only as a visual badge must + still have a complete singular/plural TalkBack description. +- Keep protocol tokens, image names, usernames, resource names, hostnames, versions, IDs, and + server-provided error details as data arguments. Do not mark them translatable and do not alter + their case with the current locale. +- Content descriptions explain purpose or state, not icon appearance. Decorative icons use `null`. + A visible label and its duplicate icon description must not create two TalkBack stops. +- New user-visible text is resource-backed. A focused change may leave unrelated existing English + text alone, but must not add to that debt. + +The first PAR-401 slice covers authentication, top-level/adaptive navigation and customization, +container/project destructive confirmations, durable-operation status/actions/counts, shared retry, +and accessibility-critical loading/state text. Debug builds generate Android's `en-XA` accented and +`ar-XB` bidirectional pseudolocales. The release artifact does not advertise pseudolocales. + +Exercise the migrated flows with: + +```sh +adb shell setprop persist.sys.locale en-XA +adb shell settings put system font_scale 2.0 +adb reboot +``` + +Repeat with `ar-XB`, then restore the original locale and `font_scale=1.0`. Verify long expansion, +argument ordering, bidirectional isolation of user/server data, mirrored layout and Back/navigation +placement, dialogs/sheets, and that critical actions remain reachable by scrolling. Locale changes on +modern test images may instead be made through system Settings; the evidence record must state which +method and image were used. + +## Accessibility implementation rules + +- Every mutation has a descriptive visible label and a minimum 48 dp interaction target. Destructive + actions include the resource and environment when available, require an explicit confirmation, and + never use Back/outside dismissal as confirmation. +- Selected navigation/environment state exposes selected or state-description semantics, not color + alone. Status chips and resource rows retain text or shape/icon differences in light and dark mode. +- Screen/section titles are headings where doing so improves navigation. Lists preserve visual reading + order; trailing controls are reached after their row identity. Dialog focus remains inside the + dialog until it is dismissed. +- Loading and determinate progress expose progress semantics. Authentication restoration does not + announce or flash the login route. Error, stale/offline, operation transition, and completion text + use polite live regions where repeated interruption would be harmful. +- Material components provide keyboard/D-pad focus by default. Custom canvas/topology content must + have real focusable semantic nodes and a direct grouped-list fallback; drawn edges are silent. +- Compose animation observes the platform animator-duration setting. Continuous shimmer, pulse, + progress, and topology feedback must remain understandable at animation scale 0 and must not be the + only indication of activity or state. +- Haptics are reserved for useful acknowledgement such as an intentional long press. They honor + platform/user settings and always accompany a visible or spoken result. + +## Manual audit matrix + +For each representative compact and expanded configuration, check TalkBack swipe exploration, +keyboard Tab/Shift-Tab/Enter/Back or D-pad, touch targets, focus after navigation, 100% and 200% font, +light/dark/automatic theme, portrait/landscape, and animation scale 1x/0x. + +| Surface | Required observations | +| --- | --- | +| Authentication | Server/username/password fields have stable labels and appropriate keyboard/security behavior; errors are announced; loading/restoration never exposes the wrong route; browser/MFA return and Back remain predictable. | +| Adaptive navigation | Compact bar, medium rail/More sheet, expanded drawer, tab replacement/reset, selected state, reselect-to-root, and Back order are labeled and keyboard reachable. | +| Dashboard | Fleet totals, Needs Attention, failed-activity badge, pinned rows/actions, environment cards, stale/offline banners, Update All, and widget-authenticated routes do not rely on color or ambiguous unlabeled icons. | +| Containers | List/detail focus, status, stats progress, lifecycle actions, logs/terminal navigation, destructive confirmation, and operation completion announce truthful resource/environment context. | +| Projects and operations | Deploy/build/pull/down/delete confirmation, operation progress/reconnect/failure/completion, cancel/retry/dismiss, Activity navigation, and rotation/process recovery retain one understandable state. | +| Settings and environments | Settings hierarchy, switches, dialogs, credential-sensitive fields, environment selection/actions, sheets, account/admin restrictions, and Back do not trap or reorder focus. | +| Widgets and authenticated routes | Widget state is textual, sizes do not clip critical status, signed-out/stale/unavailable routes are fenced, and launcher/shortcut/notification entry returns through authenticated navigation. | +| Topology | Nodes expose name/type/status/connection count, selection is non-color-only, pan/zoom does not strand focus, details are reachable, and the grouped-list fallback is immediately available. | + +Automated Compose tests assert representative route state, selected/button roles, destructive-dialog +behavior, adaptive navigation interaction, and operation status/action semantics. These checks catch +regressions in the semantics tree, but they do not replace spoken-output, traversal, contrast, +font-scale, reduced-motion, switch-access, or real keyboard testing. + +## Evidence record + +The release-readiness PR must append the actual AVD/device/API, locale, theme, font scale, input +method, TalkBack version, animator scale, passed surfaces, limitations, and any sanitized retained +assets. Do not mark PAR-401 or PAR-402 complete merely because the automated suite passes. diff --git a/docs/fdroid-release-preparation.md b/docs/fdroid-release-preparation.md new file mode 100644 index 0000000..1708c30 --- /dev/null +++ b/docs/fdroid-release-preparation.md @@ -0,0 +1,151 @@ +# F-Droid release preparation + +This is preparation evidence, not a submission or a reproducibility claim. The authoritative inputs +were rechecked against the current [F-Droid inclusion policy](https://f-droid.org/docs/Inclusion_Policy/), +[app metadata reference](https://f-droid.org/docs/Build_Metadata_Reference/), +[submission quick start](https://f-droid.org/docs/Submitting_to_F-Droid_Quick_Start_Guide/), +[reproducible-build guidance](https://f-droid.org/docs/Reproducible_Builds/), and +[anti-feature definitions](https://f-droid.org/docs/Anti-Features/) on 2026-09-16. + +## Identity and release inputs + +| Field | Prepared value | +| --- | --- | +| Application ID | `app.getarcane.android` | +| Current version | `0.1.0` / version code `260901` | +| Android support | min API 24; compile/target API 35 | +| License | BSD-3-Clause; root `LICENSE`, copyright Kyle Mendell | +| Source | `https://github.com/getarcaneapp/android` | +| Issues | `https://github.com/getarcaneapp/android/issues` | +| Category | System | +| Build tools | JDK 21, Gradle wrapper 9.4.1, AGP 9.1.1, Kotlin 2.2.10, Android platform/build-tools 35 | +| Native toolchain | No NDK/CMake/native library build required | +| Metadata | `fastlane/metadata/android/en-US/` title, short/full description, and version-code changelogs | +| Icon | Adaptive launcher icon is built entirely from repository resources; F-Droid may extract it from the APK. No store screenshot is required for initial metadata. | + +The app is an operational client for a user-configured Arcane server. It makes network requests only +to that configured server and to resource endpoints the server/user asks it to use. The optional +hosted demo is user-selected. Browser surfaces may be opened for server-configured OIDC/passkey +flows. There is no ad, analytics, telemetry, crash-reporting, Firebase, billing, or proprietary push +dependency, and no tracker was found. However, the packaged dependency graph does contain Google Play +Services auth/FIDO libraries through AndroidX Credentials; that is a proprietary build/runtime +dependency and is not represented as F-Droid eligible. +An Arcane server is required; the app does not silently provision or subscribe to a hosted service. + +Any future metadata must declare the applicable `NonFreeDep` anti-feature while the Google Play +Services credentials provider remains packaged. Merely declaring an anti-feature does not cure the +inclusion-policy problem: the dependency must be removed/replaced, or current F-Droid policy and +tooling must explicitly accept the exact build. Network-service anti-features must also be +re-evaluated against the exact release behavior and disclosures at submission time. + +## Source and dependency audit + +All normal Android libraries resolve from Google Maven or Maven Central and use established FOSS +licenses (AndroidX/Compose/Gradle/Kotlin/Ktor/coroutines/Coil). The only application-specific source +dependency is `libarcane-kotlin`. Local builds prefer a sibling checkout with a matching AGP version; +otherwise Gradle source control fetches its public Git repository. No `.aar`, `.jar` library, `.so`, +APK, keystore, certificate, generated source archive, or dynamically downloaded executable is +committed; `gradle-wrapper.jar` is the expected bootstrap binary. + +There are two release-blocking eligibility issues. First, the pinned `libarcane-kotlin` repository at +`b29695d547b78389ed7230b35cd133f7046b4b52` has no `LICENSE`, `COPYING`, or equivalent license +declaration. F-Droid requires all dependencies to be verifiably free software. The Android project's +BSD-3-Clause license cannot be presumed to license a separate repository. A future submission must +remain blocked until upstream adds an explicit compatible license and the exact pinned SDK revision +contains it. This batch does not rewrite or infer upstream licensing. + +Second, release dependency and packaged-manifest inspection found +`androidx.credentials:credentials-play-services-auth:1.6.0` and its transitive +`com.google.android.gms` auth, FIDO, block-store, identity-credentials, base, basement, and tasks +artifacts. The merged manifest confirms their credential-provider and revocation components. These +are used for the current passkey integration but are proprietary dependencies. A future F-Droid build +must use an audited FOSS-only credential-provider strategy or otherwise resolve this policy blocker; +the candidate recipe below is therefore structural documentation, not a buildable eligible recipe. + +The remote Gradle fallback follows the SDK `main` branch and is useful for ordinary clean-checkout CI, +but is not a reproducible release input. An F-Droid recipe must use a separately declared `srclib` +for `https://github.com/getarcaneapp/libarcane-kotlin.git` at an exact full commit, place/symlink it at +`../libarcane-kotlin`, and let the existing composite build consume it. Network fetching from Gradle +source control during the isolated build must not be relied upon. + +Ordinary fallback buildability was verified from a fresh GitHub clone of Android +`94e972ae03afd3a9fe133ad78a2861fadc4fc36d` with no sibling SDK, `local.properties`, or signing +variables. `-Parcane.remoteSdk` resolved public SDK `main` to +`b29695d547b78389ed7230b35cd133f7046b4b52`; 380 unit tests and debug/unsigned-release assembly +passed. This validates the documented fallback, not the candidate F-Droid recipe or reproducibility. + +Candidate external metadata structure after the SDK license blocker is resolved: + +```yaml +Categories: + - System +License: BSD-3-Clause +SourceCode: https://github.com/getarcaneapp/android +IssueTracker: https://github.com/getarcaneapp/android/issues +RepoType: git +Repo: https://github.com/getarcaneapp/android.git + +Builds: + - versionName: 0.1.0 + versionCode: 260901 + commit: + subdir: app + srclibs: + - libarcane-kotlin@ + prebuild: ln -s $$libarcane-kotlin$$ ../../libarcane-kotlin + gradle: + - yes + +AutoUpdateMode: Version +UpdateCheckMode: Tags +CurrentVersion: 0.1.0 +CurrentVersionCode: 260901 +``` + +This snippet intentionally is not installed as executable repository metadata: the release commit and +licensed SDK commit do not exist yet, and knowingly committing placeholders as a claimed valid recipe +would be misleading. F-Droid's external `fdroiddata` change remains a separately authorized future +submission. + +## Build and signing boundary + +A future release candidate is built from a clean tag checkout with a clean exact SDK sibling: + +```sh +./gradlew --no-daemon :app:testDebugUnitTest :app:lintDebug +./gradlew --no-daemon :app:assembleRelease +``` + +With no `ARCANE_RELEASE_*` variables, the second command creates an unsigned release APK. This is the +artifact boundary suitable for F-Droid to sign with its own infrastructure. Upstream GitHub signing, +if explicitly authorized, uses repository secrets and a separate keystore; neither key material nor a +signed artifact is a source input. A successful clean build proves buildability, not bit-for-bit +reproducibility. Reproducibility may be claimed only after at least two clean isolated builds with +identical declared inputs produce matching normalized/unsigned artifacts and the official F-Droid +tooling confirms the result. + +Release tags use `v` (for example `v0.1.0`) and must point at the commit whose Gradle +version name/code and `fastlane` changelog agree. The source archive is the forge-generated archive of +that tag. The publisher verifies the tag, changelog, metadata, dependency pin, unsigned artifact, +manifest/permissions, and update-check result before any signing or submission action. + +## Validation and remaining submission work + +Before a future F-Droid metadata PR: + +1. Land an explicit FOSS license in `libarcane-kotlin`, remove or replace the proprietary Google Play + Services credential dependency for the F-Droid build, select the resulting full commit, and + define/validate the `srclib` entry. +2. Cut no tag until the release checklist, upgrade/rollback matrix, and publication authorization are + complete. +3. Repeat the isolated Android+SDK build without local properties, signing variables, or caches; + validate the candidate YAML with the current `fdroidserver` lint/build tools. +4. Re-run dependency/APK/manifest/tracker/anti-feature scans and verify the adaptive icon plus + English metadata. Add screenshots only if sanitized disposable data materially improves the + listing; screenshots must contain no real host, user, credential, resource identifier, or + notification content. +5. Submit the external metadata PR separately. Do not upload upstream signing keys or signed APKs to + the F-Droid build recipe. + +No F-Droid submission, external metadata PR, signing key, tag, release, or store artifact is created +by PAR-405. diff --git a/docs/ios-parity-task-list.md b/docs/ios-parity-task-list.md index f61bd25..bd98c88 100644 --- a/docs/ios-parity-task-list.md +++ b/docs/ios-parity-task-list.md @@ -1216,67 +1216,107 @@ The standard checks are: ## Phase 4: Quality, accessibility, localization, and distribution -- [ ] **PAR-401 — Establish an incremental localization path** +- [x] **PAR-401 — Establish an incremental localization path** -- **Status:** Ready +- **Status:** Complete - **Priority:** P3 - **Dependencies:** None - **Scope:** Define string-resource conventions and migrate text as touched, then address the highest traffic and accessibility-critical screens. iOS is also English-only, so this is product maturity rather than a literal missing iOS parity item. - **Acceptance criteria:** - - [ ] New user-visible text is resource-backed with plural, formatting, and accessibility conventions. - - [ ] A scoped first migration covers authentication, navigation, destructive confirmations, and + - [x] New user-visible text is resource-backed with plural, formatting, and accessibility conventions. + - [x] A scoped first migration covers authentication, navigation, destructive confirmations, and operation status without combining all app text into one risky change. - - [ ] Pseudolocale checks find no clipping in the migrated flows. - - [ ] Formatting does not concatenate grammar-sensitive fragments. + - [x] Pseudolocale checks find no clipping in the migrated flows. + - [x] Formatting does not concatenate grammar-sensitive fragments. + + **Validation evidence (2026-09-16):** Compared Android + `90b67366638c21c30b2c748347a57bd8f184d491`, iOS + `8d13fdb5cd61a62b1d666e9e982a2670d86086c3`, libarcane-kotlin + `b29695d547b78389ed7230b35cd133f7046b4b52`, and Arcane + `194e7ae87f0803bc2b85ed3a9a107fd432993ac4`. The first slice resource-backs auth, top-level and + adaptive navigation, destructive confirmation, operation state/actions, counts/plurals, and shared + accessibility text under the conventions in `accessibility-and-localization.md`. Debug + pseudolocales were exercised on API 30 in English, `en-XA`, and RTL `ar-XB` at 100% and 200% font + scale. Long compact-navigation labels now ellipsize, layout mirrored correctly, user/server names + remain data arguments, and no migrated critical action was clipped or unreachable. -- [ ] **PAR-402 — Run a cross-cutting accessibility and interaction audit** +- [x] **PAR-402 — Run a cross-cutting accessibility and interaction audit** -- **Status:** Ready +- **Status:** Complete - **Priority:** P2 - **Dependencies:** PAR-304 - **Scope:** Audit TalkBack semantics, focus order, touch targets, contrast, font scaling, reduced motion, progress announcements, destructive confirmations, and useful haptic feedback. - **Acceptance criteria:** - - [ ] Core auth, navigation, dashboard, container action, project operation, and Settings flows pass a + - [x] Core auth, navigation, dashboard, container action, project operation, and Settings flows pass a documented manual accessibility checklist. - - [ ] Automated Compose accessibility checks cover representative screens. - - [ ] At 200% font scale, critical actions and status remain reachable and understandable. - - [ ] Motion/haptics convey state without becoming the only signal. - -- [ ] **PAR-403 — Add focused UI and live-server test foundations** + - [x] Automated Compose accessibility checks cover representative screens. + - [x] At 200% font scale, critical actions and status remain reachable and understandable. + - [x] Motion/haptics convey state without becoming the only signal. + + **Audit evidence (2026-09-16):** Auth, adaptive navigation, Dashboard, pinned/environment actions, + container confirmation, project/operation state, Settings, widgets/routes, and topology/list fallback + were checked against the documented semantics/focus/contrast/font/motion checklist. Fixes add + headings, button/selected/state/progress semantics, polite live regions, non-color status cues, + decorative-icon silence, accessible semantic colors, and correct password-field focus. API 30 + covered touch, D-pad/keyboard, compact/expanded/rotation, three themes, pseudolocales, and 200% font; + API 35 enabled TalkBack and proved labeled focus traversal. Nine deterministic Compose/route tests + passed on APIs 24, 30, and 35. No physical-device or recorded spoken-output audit is claimed; that + remains a beta-promotion requirement rather than an open defect in this scoped audit. + +- [x] **PAR-403 — Add focused UI and live-server test foundations** -- **Status:** Ready +- **Status:** Complete - **Priority:** P2 - **Dependencies:** PAR-001, PAR-005 - **Scope:** Replace the template instrumentation test with a small reliable suite for authentication routing, configurable admin tabs, destructive confirmation, and environment switching. Define a disposable live-server harness for destructive/streaming validation. - **Acceptance criteria:** - - [ ] Tests are deterministic, use controlled fixtures/fakes where appropriate, and run on a documented + - [x] Tests are deterministic, use controlled fixtures/fakes where appropriate, and run on a documented emulator API level. - - [ ] CI runs the selected instrumentation suite or clearly separates a scheduled/manual device lane. - - [ ] Live-server tests cannot target an unapproved production server and clean up disposable state. - - [ ] Unit, instrumented, and live-server claims remain separately reported. + - [x] CI runs the selected instrumentation suite or clearly separates a scheduled/manual device lane. + - [x] Live-server tests cannot target an unapproved production server and clean up disposable state. + - [x] Unit, instrumented, and live-server claims remain separately reported. -- [ ] **PAR-404 — Add incremental static-quality and security gates** + **Validation evidence (2026-09-16):** The template test is replaced by nine bounded tests covering + auth restoration/routing, authenticated routes, configurable/adaptive navigation, environment + selection, pin persistence, destructive Back behavior, system-bar policy, and representative + operation cancellation. They passed 9/9 on APIs 24, 30, and 35; CI pins an API 30 emulator lane. + `scripts/run-disposable-live-tests.sh` requires explicit opt-in, allowlisted disposable URLs, + environment-only credentials, unique resource prefixes, TLS verification, and a mandatory cleanup + callback. Unit (380), instrumentation, and Arcane 2.10.2/2.11.1 live evidence are separately + reported in `release-readiness.md`. -- **Status:** Ready +- [x] **PAR-404 — Add incremental static-quality and security gates** + +- **Status:** Complete - **Priority:** P3 - **Dependencies:** None - **Scope:** Add Android lint first, then evaluate focused formatting/static analysis and dependency or secret scanning without introducing a noisy all-at-once migration. - **Acceptance criteria:** - - [ ] Each enabled gate has a documented baseline and fails only on actionable new violations. - - [ ] Security-sensitive manifest, backup, exported-component, cleartext, and dependency findings are + - [x] Each enabled gate has a documented baseline and fails only on actionable new violations. + - [x] Security-sensitive manifest, backup, exported-component, cleartext, and dependency findings are triaged rather than blanket-suppressed. - - [ ] CI runtime and local commands are documented. - - [ ] No mass reformat or unrelated cleanup is bundled with gate enablement. + - [x] CI runtime and local commands are documented. + - [x] No mass reformat or unrelated cleanup is bundled with gate enablement. + + **Gate evidence (2026-09-16):** Local and CI lint run with abort-on-error, warnings-as-errors, and + release checks. `:app:lintDebug` passed with zero new findings against a path-portable baseline of + 45 exact pre-existing errors and one hint (SDK/target/dependency currency, deliberate API-gated + resources, launcher-icon debt, overdraw, and shortcut ranking). Actionable locale/default-format/ + redundant findings were fixed instead of baselined. The merged unsigned APK was inspected for all + permissions and exported components, backup rules, cleartext, shortcuts, widget provider, and + signing. The Google Play credentials/FIDO dependency and missing SDK license are explicitly + triaged as F-Droid blockers. CI actions are immutable-SHA pinned; formatting/detekt/scanner gates + remain intentionally unelected until they can have owned actionable baselines. - [ ] **PAR-405 — Prepare F-Droid packaging and metadata** -- **Status:** Ready +- **Status:** Blocked — repository preparation complete; upstream dependency eligibility unresolved - **Priority:** P2 - **Dependencies:** PAR-006, PAR-007 - **Scope:** Research and prepare reproducible F-Droid-compatible release packaging and metadata, @@ -1285,27 +1325,45 @@ The standard checks are: - **Acceptance criteria:** - [ ] The build recipe succeeds from a clean checkout without proprietary build-time dependencies or uncommitted machine configuration. - - [ ] Version code/name, supported SDKs, signing boundary, update metadata, and release-source tag + - [x] Version code/name, supported SDKs, signing boundary, update metadata, and release-source tag workflow are documented. - [ ] Metadata, fastlane assets if chosen, license, privacy/network disclosures, and anti-feature declarations pass applicable F-Droid validation. - - [ ] No signing key or release is created/published as part of this preparation task without separate + - [x] No signing key or release is created/published as part of this preparation task without separate authorization. -- [ ] **PAR-406 — Define Android alpha/beta release criteria** + **Preparation evidence (2026-09-16):** Official inclusion, metadata, submission, reproducible-build, + and anti-feature guidance was rechecked and recorded in `fdroid-release-preparation.md`. Identity, + BSD-3-Clause app license/notices, Fastlane metadata including `260901`, toolchain, source/tag/update + policy, unsigned signing boundary, network disclosure, and an exact-srclib recipe design are + documented. A fresh clone of Android `94e972ae03afd3a9fe133ad78a2861fadc4fc36d` + with `-Parcane.remoteSdk`, no sibling/local configuration, and no signing variables resolved SDK + `b29695d547b78389ed7230b35cd133f7046b4b52`, passed 380 unit tests, and assembled debug plus unsigned + release APKs. Eligibility is still blocked: the pinned SDK has no license file, and its Android + credentials module packages proprietary Google Play Services auth/FIDO dependencies. Therefore no + official-tool recipe success, reproducibility, or metadata-pass claim is made. No key, tag, + submission, external metadata PR, or release was created. + +- [x] **PAR-406 — Define Android alpha/beta release criteria** -- **Status:** Ready +- **Status:** Complete - **Priority:** P3 - **Dependencies:** PAR-006, PAR-403, PAR-405 - **Scope:** Replace ambiguous “not intended for devices” messaging with explicit support, compatibility, verification, known-limitations, and release-channel criteria. - **Acceptance criteria:** - - [ ] Minimum supported Arcane/server, Android, and Kotlin SDK compatibility expectations are stated. - - [ ] Alpha/beta promotion gates cover builds, tests, device/live-server matrix, privacy, backup, + - [x] Minimum supported Arcane/server, Android, and Kotlin SDK compatibility expectations are stated. + - [x] Alpha/beta promotion gates cover builds, tests, device/live-server matrix, privacy, backup, upgrade, rollback, and release-note integrity. - - [ ] Distribution channels and signing/publishing responsibilities are documented without embedding + - [x] Distribution channels and signing/publishing responsibilities are documented without embedding credentials. - - [ ] User-facing repository messaging matches the actual release state. + - [x] User-facing repository messaging matches the actual release state. + + **Policy evidence (2026-09-16):** `release-readiness.md` defines API 24/30/35 and Arcane + 2.10.2/latest-stable/current-source expectations, exact SDK pinning, alpha/beta entry and exit gates, + blocker severity, backup/offline/upgrade/rollback/release-note checks, version/tag workflow, known + limitations, and separate merge/tag/sign/publish/F-Droid/store authorization. README now identifies + the app as a public alpha and links the durable policy without claiming production readiness. ## Hold and deferred product tracks @@ -1448,28 +1506,41 @@ The standard checks are: These items appear to have progressed or landed in later workspace notes. They are not active implementation work unless current-source or runtime verification finds a regression. -- [ ] **PAR-V01 — Pinned dashboard resources and context actions** +- [x] **PAR-V01 — Pinned dashboard resources and context actions** -- **Status:** Done/verify +- **Status:** Complete - **Priority:** P1 if reopened - **Dependencies:** PAR-004 - **Scope:** Verify pins, context actions, persistence, permission changes, and correct environment targeting on current source. - **Acceptance criteria:** - - [ ] More than one environment and process recreation preserve the intended pins. - - [ ] Unauthorized/stale resources disappear or become safely unavailable. - - [ ] Close as verified or reopen with a focused reproduction. + - [x] More than one environment and process recreation preserve the intended pins. + - [x] Unauthorized/stale resources disappear or become safely unavailable. + - [x] Close as verified or reopen with a focused reproduction. + + **Verification evidence (2026-09-16):** On API 30 against Arcane 2.10.2 with a second disposable + environment registered, a real `arcane-e2e` container was pinned from its context menu, survived + force-stop/reopen, rendered on Dashboard, and its Open action returned to the exact container and + environment; Unpin remained available. The 380-test baseline includes persisted-pin restoration and + current authorization/resource filtering, and the device instrumentation suite includes pin-store + process persistence. No stale or cross-environment action was observed. -- [ ] **PAR-V02 — Needs Attention action items** +- [x] **PAR-V02 — Needs Attention action items** -- **Status:** Done/verify +- **Status:** Complete - **Priority:** P1 if reopened - **Dependencies:** PAR-004 - **Scope:** Verify counts, actions, navigation, partial failures, and authorization on current source. - **Acceptance criteria:** - - [ ] Items navigate to the correct server/environment/resource. - - [ ] Fleet pagination and partial environment failure do not create false totals. - - [ ] Close as verified or reopen with a focused reproduction. + - [x] Items navigate to the correct server/environment/resource. + - [x] Fleet pagination and partial environment failure do not create false totals. + - [x] Close as verified or reopen with a focused reproduction. + + **Verification evidence (2026-09-16):** The live image-update action opened the image-oriented + Updates destination with retained environment context, and the failed-activity action opened the + global Activity Center with correct environment attribution. Complete-list/dedupe and partial-data + mapping tests prevent paginated or unavailable environments from publishing a false fleet total; + the offline live pass retained labeled data instead of inventing a partial success. - [x] **PAR-V03 — Dashboard stream foundation and live-stats recovery** @@ -1499,51 +1570,78 @@ implementation work unless current-source or runtime verification finds a regres - `./gradlew :app:testDebugUnitTest :app:assembleDebug` passed all 148 unit tests and assembled the debug APK; `git diff --check` passed. -- [ ] **PAR-V04 — Update All environments** +- [x] **PAR-V04 — Update All environments** -- **Status:** Done/verify +- **Status:** Complete - **Priority:** P1 if reopened - **Dependencies:** PAR-004, PAR-501 - **Scope:** Verify complete environment coverage and result reporting while preserving PAR-501's completed Updates counts/navigation decision. - **Acceptance criteria:** - - [ ] More than 20 environments are included exactly once where eligible. - - [ ] Partial, unsupported, unauthorized, cancel, and error results are accurately attributed. - - [ ] Close as verified or reopen outside PAR-501 only with independent evidence. + - [x] More than 20 environments are included exactly once where eligible. + - [x] Partial, unsupported, unauthorized, cancel, and error results are accurately attributed. + - [x] Close as verified or reopen outside PAR-501 only with independent evidence. + + **Verification evidence (2026-09-16):** A deterministic 26-environment result fixture proves every + unique environment is counted exactly once and separately attributes updated, failed, offline, + current, and pending results; permission gating and restart/connection notes are separately tested. + The live two-environment prompt named both eligible environments. Cancellation/error handling is + supported by the typed job-state source and unit matrix; no live manager self-update was triggered + solely to manufacture evidence. -- [ ] **PAR-V05 — Environment card actions** +- [x] **PAR-V05 — Environment card actions** -- **Status:** Done/verify +- **Status:** Complete - **Priority:** P1 if reopened - **Dependencies:** PAR-102 - **Scope:** Verify current sync, system, upgrade, prune, detail, and active-environment actions against permissions and server capabilities. - **Acceptance criteria:** - - [ ] Every visible action targets the card's environment and has an accurate enabled state. - - [ ] Upgrade behavior is tracked by PAR-102 and prune runtime behavior by PAR-101. - - [ ] Close remaining actions as verified or reopen individually. + - [x] Every visible action targets the card's environment and has an accurate enabled state. + - [x] Upgrade behavior is tracked by PAR-102 and prune runtime behavior by PAR-101. + - [x] Close remaining actions as verified or reopen individually. + + **Verification evidence (2026-09-16):** The previously UI-only Sync action now invokes the typed SDK + environment sync, is environment-fenced, prevents duplicates, invalidates relevant cache state, and + reports completion/error. Live v2.10.2 displayed `Syncing Local Docker…`. The active card hides Use + Environment; Upgrade appears only for positive availability; an offline disposable card kept + recovery/read/Sync actions available while disabling Upgrade and Prune. Permission/active/offline + policies are covered by focused unit tests; PAR-101/PAR-102 retain their destructive runtime scope. -- [ ] **PAR-V06 — Failed Activity badge** +- [x] **PAR-V06 — Failed Activity badge** -- **Status:** Done/verify +- **Status:** Complete - **Priority:** P1 if reopened - **Dependencies:** PAR-004, PAR-109 - **Scope:** Verify count, environment attribution, clearing/retry behavior, and navigation. - **Acceptance criteria:** - - [ ] Pagination, live updates, clearing, and partial failures cannot leave a misleading badge. - - [ ] Tapping opens the relevant Activity Center context. - - [ ] Close as verified or reopen with a focused reproduction. + - [x] Pagination, live updates, clearing, and partial failures cannot leave a misleading badge. + - [x] Tapping opens the relevant Activity Center context. + - [x] Close as verified or reopen with a focused reproduction. + + **Verification evidence (2026-09-16):** Live v2.10.2 showed the capped `9+` header badge and the + full failed count (`72`) in Needs Attention; both opened the fleet Activity Center and retained + environment attribution across paginated failures. Mapping tests use the complete authoritative + count, hide zero, and withhold misleading partial/unavailable totals; badge formatting covers zero, + one, nine, and double-digit counts. Activity stream recovery/clearing remains covered by the + existing reliability suite. -- [ ] **PAR-V07 — Black bottom inset** +- [x] **PAR-V07 — Black bottom inset** -- **Status:** Done/verify +- **Status:** Complete - **Priority:** P2 if reopened - **Dependencies:** None - **Scope:** Check representative light/dark themes, gesture/three-button navigation, keyboard, rotation, and edge-to-edge screens. - **Acceptance criteria:** - - [ ] No unintended black inset appears across the checked configurations. - - [ ] Close as verified or reopen with screenshots, device/API details, and a focused reproduction. + - [x] No unintended black inset appears across the checked configurations. + - [x] Close as verified or reopen with screenshots, device/API details, and a focused reproduction. + + **Verification evidence (2026-09-16):** `MainActivity` disables the platform navigation-bar contrast + scrim on API 29+ and the API-gated instrumentation assertion passed on APIs 30 and 35. API 30 visual + checks covered light/dark/automatic themes, gesture and three-button navigation, keyboard display, + rotation, and compact/expanded edge-to-edge layouts with no unintended black bottom inset. Temporary + screenshots were deleted after inspection. ## Backlog maintenance diff --git a/docs/release-readiness.md b/docs/release-readiness.md new file mode 100644 index 0000000..d313260 --- /dev/null +++ b/docs/release-readiness.md @@ -0,0 +1,173 @@ +# Android release channels and promotion policy + +Arcane Mobile for Android is currently a public **alpha**. It is suitable for informed testing +against disposable or well-backed-up environments, but it is not yet represented as production-ready. +The app can perform destructive Docker operations; server-side backups and a recovery path remain the +operator's responsibility. + +## Supported and tested baseline + +- Android 7.0 / API 24 is the minimum install and core-flow target. API 35 is the compile/target SDK. + Every release candidate covers API 24 minimum smoke, API 30 retained regression, and API 35 current + permissions/background behavior. Compact portrait/landscape and at least one tablet or foldable + expanded configuration are required. +- Arcane 2.10.2 is the minimum fully supported alpha server baseline. Features introduced after that + version remain capability-gated and may show an explicit unsupported state. Each candidate tests + 2.10.2 plus the latest stable 2.11.x-or-newer release and current Arcane source when practical. +- Android consumes `libarcane-kotlin` as its only Arcane API client. A release records one exact SDK + commit and validates both the matching sibling composite build and the public remote fallback. An + F-Droid recipe must materialize the exact SDK commit as a sibling `srclib`; a moving `main` branch is + never a release pin. +- The current preparation comparison pins are Android + `90b67366638c21c30b2c748347a57bd8f184d491`, iOS + `8d13fdb5cd61a62b1d666e9e982a2670d86086c3`, libarcane-kotlin + `b29695d547b78389ed7230b35cd133f7046b4b52`, and Arcane current source + `194e7ae87f0803bc2b85ed3a9a107fd432993ac4` (`v2.12.0-5-g194e7ae8`). + +## Candidate checklist + +Every alpha or beta candidate records exact revisions, test counts, device/server configurations, +failures/limitations, and cleanup. Required evidence is: + +- green unit tests and debug assembly; Android lint with no unbaselined findings; +- deterministic API 30 instrumentation for auth routing, navigation/configurable tabs, adaptive UI, + destructive confirmation, environment switching, authenticated routes, and representative durable + operations; +- disposable live-server coverage of authentication/restoration, multi-environment/restricted-user + behavior, operations/notifications, widgets/shortcuts/routes, cache/snapshot/offline recovery, + update and Activity surfaces, destructive confirmation, force-stop/reopen, and process recreation; +- API 24 install/launch/core smoke, API 30 retained regression, API 35 permission/background behavior, + compact plus expanded layouts, portrait/landscape, light/dark/automatic themes, keyboard/D-pad, and + representative TalkBack; +- English, accented `en-XA`, and RTL `ar-XB` pseudolocales at 100% and 200% font scale for migrated + flows, including animation scale 0/reduced-motion observations; +- backup/restore allowlist inspection and a disposable restore regression; previous-debug-to-candidate + upgrade plus rollback/data-policy observations; +- clean-checkout remote-SDK fallback build, matching sibling-SDK build, unsigned release build, packaged + manifest/permissions/exported components/shortcuts/widget/backup/signing inspection, dependency and + secret/artifact scan, `git diff --check`, and an independent complete-diff/release review; +- accurate changelog/release notes, known limitations, F-Droid metadata state, source-tag/version + agreement, and all disposable users/resources/instances/AVDs/APKs/widgets/screenshots/certificates/ + trust overrides/build directories removed. + +Unit, instrumented, and live lanes are reported separately. A synthetic or mocked result never replaces +a required real-server/device observation. Automated reviews are not currently active; no Greptile +evidence is required or claimed. + +## Alpha and beta gates + +An **alpha** may be cut when all blocker checks above are green for its declared scope, no known +security/data-loss/crash blocker exists, destructive actions are correctly scoped and confirmed, the +minimum supported server/device flows work, and limitations are prominent in the release notes. Alpha +may retain bounded noncritical accessibility/localization gaps or explicit capability-gated feature +limits, but not misleading state, credential exposure, cross-server actions, or unrecoverable data +corruption. + +Promotion to **beta** additionally requires two consecutive release candidates with the complete +matrix green, no unresolved P0/P1 parity verification candidate, representative physical-device +confirmation, stable upgrade/backup/offline behavior, completed English accessibility audit with no +critical WCAG/Android accessibility defect, pseudolocale readiness for the migrated high-traffic +slice, an explicit dependency-license/F-Droid eligibility decision, and no open crash/data-loss/ +security issue. Beta still does not imply a production SLA or support for every Arcane feature. + +Any reproducible credential disclosure, cross-account/server/environment mutation, backup inclusion of +sensitive state, destructive action without adequate confirmation, corrupt upgrade, unrecoverable +operation duplication, startup/auth crash on a supported baseline, or known exploitable dependency is +a release blocker. Stop publication, document impact, rotate exposed test credentials if relevant, +fix and rerun the affected complete matrix. Lower-severity defects require an owner, user-visible note +when relevant, and an explicit accept/defer decision. + +## Version, upgrade, and rollback workflow + +- `versionName` uses semantic versions. Pre-release labels are allowed while channel state requires + them. `versionCode` is monotonically increasing; the current date-shaped convention must never be + reused or decreased. +- Update `app/build.gradle.kts` and add the matching + `fastlane/metadata/android/en-US/changelogs/.txt` in the candidate commit. The changelog + describes user-visible behavior, compatibility, migrations, security notes, and known limitations. +- Create `v` only after the exact candidate commit, clean tree, CI, release review, and + explicit tag/publication authorization are recorded. The tag, source archive, Gradle version, and + metadata must agree. +- Upgrade testing installs the prior published debug/release-equivalent artifact with sanitized + disposable state, then installs the candidate without clearing data and repeats auth, navigation, + cache, operation, widget, backup-policy, and server-scope checks. +- Android normally prevents a lower-version-code downgrade. Test rollback only on disposable data. + A production rollback is a newly versioned forward release built from the last safe source plus any + required data migration; never instruct users to bypass downgrade protection or assume newer stored + state is readable by an older binary. + +## Distribution and authority + +Pull requests automatically produce an unsigned/debug GitHub artifact for review. Release signing is +optional and bounded to repository secrets on an explicitly authorized version tag. Maintainers with +the relevant repository/release credentials may perform publication only after the checklist is +approved. Merge, tag creation, release signing, GitHub Release publication, F-Droid submission, +external metadata PRs, and any eventual store upload are separate actions that each require explicit +authorization; preparing this repository does not authorize any of them. + +F-Droid signs its own unsigned build from source. Upstream signing keys are never shared with F-Droid +or committed. Eventual Play/other store accounts, signing custody, staged rollout, rollback, privacy +listing, and reviewer responses remain maintainer responsibilities and are not delegated to an +automated test or PR author. + +## PAR-401–406 validation record (2026-09-16) + +The batch compared Android `90b67366638c21c30b2c748347a57bd8f184d491`, iOS +`8d13fdb5cd61a62b1d666e9e982a2670d86086c3`, libarcane-kotlin +`b29695d547b78389ed7230b35cd133f7046b4b52`, and Arcane +`194e7ae87f0803bc2b85ed3a9a107fd432993ac4` (`v2.12.0-5-g194e7ae8`). The local SDK contract was +sufficient; no SDK source change or SDK PR was required. + +- **Automated lanes:** `:app:testDebugUnitTest :app:assembleDebug` passed 380 tests in 66 suites with + zero failures/errors/skips. `:app:lintDebug` passed with no new findings; the reviewed baseline has + 45 exact pre-existing errors and one advisory hint. The unsigned `:app:assembleRelease` build + passed, and `apksigner` rejected the output as unsigned as intended. A fresh GitHub clone of commit + `94e972ae03afd3a9fe133ad78a2861fadc4fc36d` had no sibling SDK, `local.properties`, or signing + variables; `-Parcane.remoteSdk` resolved public SDK `main` to + `b29695d547b78389ed7230b35cd133f7046b4b52` and passed all 380 unit tests plus debug and unsigned + release assembly. Instrumentation passed 9/9 on a fresh API 24 image, the retained API 30 + regression image, and a fresh API 35 image. The API 24 run installed/launched the app and exercised + the suite; its API-29 system-bar assertion was assumption-skipped internally. +- **Server lane:** API 30 authenticated against disposable Arcane 2.10.2 image digest + `sha256:62d8001c3568e03acf66b53d4bdd97fcca59ae9e43f1561d8f720f38b738ffbc` and a second isolated + Arcane 2.11.1 image digest + `sha256:527af49bc86e22d34de5c245b278852f347205c23016387931c6f4db1feb573b`. + Current Arcane source was contract-inspected but not separately built live. Authentication restore, + force-stop/reopen, multi-environment behavior, pins/routes, Update/Needs Attention/Activity entry + points, environment sync, offline retained state/reconnect, shortcuts, notification channels, + destructive-dialog cancellation, and the registered widget provider were exercised. The real + destructive operation was dismissed; no live mutation was performed merely to satisfy a test. +- **Device/UI lane:** API 30 covered compact portrait/landscape, tablet-like and foldable-like display + overrides, light/dark/automatic themes, gesture and three-button navigation, keyboard/D-pad, 100% + and 200% font scale, English, accented `en-XA`, and RTL `ar-XB`. API 35 covered notification-denied + launch/force-stop recovery, TalkBack service enablement and labeled keyboard focus traversal, widget + and shortcut registration, and a credential-free backup attempt. The backup transport returned + `Backup is not allowed`; the source/packaged allowlists were therefore verified, but no successful + cloud restore round trip is claimed. No physical device or recorded spoken-output audit was + available; those remain beta-promotion evidence, not hidden alpha claims. +- **Upgrade/rollback:** a disposable copy of the API 30 AVD installed prior version-code `260602` + source (`358ab241bfc95e74234fc91a9968b9fcb73654ab`, paired with historical SDK + `157131817ba96c0d8e0334f3615e7a45e6456216` and an AGP-only build compatibility adjustment), saved a + sanitized server URL without credentials, then upgraded in place to `260901`. The configured server + route survived force-stop/reopen. Reinstalling `260602` without downgrade authorization failed with + `INSTALL_FAILED_VERSION_DOWNGRADE`, confirming that rollback must be a new forward version. +- **Cleanup:** the secondary server, disposable environment, API 24/API 35/upgrade AVD copies, APKs, + user CA, debug trust override, screenshots, test data, and temporary source worktrees were removed. + The original API 30 system image and the three pre-existing AVDs were restored/preserved. No store + screenshot is retained. F-Droid remains blocked by the SDK license and proprietary credentials + dependency documented in `fdroid-release-preparation.md`. + +## Known limitations and exclusions + +- Multi-server profiles (PAR-502), an Android AI assistant (PAR-503), and speculative Swarm workflow + work (PAR-504) are intentionally deferred and are not beta promises. +- The app supports one configured Arcane server identity at a time, with multiple environments owned + by that server. Older servers expose explicit unsupported states for newer capabilities. +- Localization is incremental; the first resource-backed slice is authentication, navigation, + destructive confirmation, durable-operation state, and shared accessibility text. Unmigrated + screens remain English in this alpha. +- F-Droid submission is blocked until the separate `libarcane-kotlin` source repository carries an + explicit compatible FOSS license, the proprietary Google Play Services credentials dependency is + removed/replaced for that build, and the exact release recipe passes official isolated validation. +- Screenshots/store assets are intentionally omitted unless they can be regenerated from sanitized + disposable data and materially improve a future listing. diff --git a/docs/testing-and-quality-gates.md b/docs/testing-and-quality-gates.md new file mode 100644 index 0000000..946ec29 --- /dev/null +++ b/docs/testing-and-quality-gates.md @@ -0,0 +1,94 @@ +# Testing and quality gates + +This document defines the repeatable Android validation lanes and the security review boundary for +release-readiness changes. Unit, instrumented, and disposable-server results are always reported +separately; success in one lane is not evidence for another. + +## Local and CI lanes + +Use JDK 21 with the repository's Gradle wrapper. The required pull-request baseline is: + +```sh +./gradlew --no-daemon :app:testDebugUnitTest :app:assembleDebug +./gradlew --no-daemon :app:lintDebug +``` + +The first command normally completes in about two minutes on the reference development host after +dependencies are cached. Lint normally completes in under two minutes. CI runs those gates in the +`Build APK` job and runs the deterministic Compose/route suite separately on an API 30 x86_64 +KVM-accelerated emulator with animations disabled. The emulator lane runs for pull requests, `main` +and tag pushes, and explicit workflow dispatches; feature-branch push builds do not duplicate an open +pull request's emulator job: + +```sh +./gradlew --no-daemon :app:connectedDebugAndroidTest +``` + +The instrumented suite uses controlled UI state and invalid/example addresses; it does not depend on +an Arcane server. It covers authentication routing while restoration is pending, configurable and +adaptive navigation, environment selection, destructive confirmation, authenticated-route parsing, +and a representative durable operation. Tests wait for Compose/Espresso idleness instead of using +fixed sleeps. + +Live behavior is a distinct manual or scheduled lane. Export credentials only in the invoking shell, +never in Gradle properties, scripts, screenshots, or source. `scripts/run-disposable-live-tests.sh` +accepts only the checked disposable host/emulator URLs, requires the literal disposable opt-in, gives +every resource a unique `par-live-` prefix, and always invokes an idempotent cleanup callback. The +callback must enumerate the test prefix, delete users/resources/secondary instances created by the +run, and fail if anything remains. Commands are bounded to 900 seconds by default; set +`ARCANE_E2E_TIMEOUT_SECONDS` between 30 and 3600 only when a documented lane requires it. A private +CA path may be supplied through +`ARCANE_E2E_CA_CERT`; disabling TLS verification is not part of the durable harness. + +## Lint policy + +Android lint is an error-on-new-warning gate for debug and release builds. The checked-in +`app/lint-baseline.xml` contains only findings confirmed to predate PAR-404. A baseline entry is not a +waiver: remediation should remove the finding and its entry in the same focused change. New entries +require a written triage explaining why the code cannot reasonably be fixed now. Blanket issue +suppression and directory-wide `ignore` rules are not accepted. + +Run `./gradlew :app:updateLintBaseline` only after reviewing the full report, then inspect the XML and +use `./gradlew :app:lintDebug` to prove the resulting gate. Dependency-version suggestions, legacy +launcher-icon guidance, and deliberate minimum-SDK resource attributes are tracked as existing debt; +locale-sensitive formatting findings were fixed rather than baselined. + +Formatting and broad static-analysis plugins were evaluated but are not enabled in this batch. The +current tree has no agreed formatting baseline, so ktlint/detekt would create a large unrelated +rewrite and a noisy suppressions file. `git diff --check` remains mandatory. Revisit a formatter or +detekt in a dedicated change with an approved baseline and bounded rule set. + +## Security triage + +| Area | Current decision | +| --- | --- | +| Permissions | App source declares `INTERNET` and runtime-gated `POST_NOTIFICATIONS`. The merged release manifest also contains credentials-library `USE_BIOMETRIC`/`USE_FINGERPRINT` and WorkManager `WAKE_LOCK`, `ACCESS_NETWORK_STATE`, `RECEIVE_BOOT_COMPLETED`, and `FOREGROUND_SERVICE`, plus AndroidX's generated non-exported-receiver permission. No location, storage, camera, microphone, contacts, or package-query permission is present. | +| Exported components | `MainActivity` is exported only for launcher and narrowly parsed route/OIDC/passkey intents. The app-owned operation and widget receivers are non-exported. Merged AndroidX/Google services that must be exported are protected by `BIND_JOB_SERVICE`, `BIND_REMOTEVIEWS`, Google revocation, or `DUMP` permissions; all merged providers are non-exported. | +| Intents and shortcuts | External route payloads pass the authenticated route resolver and server/account/environment fences. Static shortcuts contain no resource or credential data. | +| Backup and transfer | Explicit allowlists include only local tab customization. Credentials, server identity, cached/server-derived state, operations, pins, snapshots, and widget data remain excluded. | +| Network security | The supported configuration requires HTTPS. No cleartext opt-in or network-security override is shipped. Test certificates and trust changes are disposable device state and are removed after testing. | +| Web/file exposure | The app has no WebView or FileProvider. Browser-based authentication uses typed SDK contracts and platform browser surfaces; no app-private file is exposed. | +| Cryptography | Application code does not invent cryptographic primitives. Credential protection, PKCE/passkey protocol work, and transport behavior remain owned by Android APIs and `libarcane-kotlin`. | +| Logging | Production code does not enable Ktor body logging and does not intentionally log credentials, tokens, passkey payloads, recovery codes, or variable secrets. Evidence and screenshots are scanned before retention. | +| Dependencies | Build inputs are Google Maven, Maven Central, Gradle plugins, and public source for `libarcane-kotlin`; no telemetry, ad, analytics, Firebase, or tracker dependency is present. The SDK's `credentials-play-services-auth` dependency brings proprietary Google Play Services auth/FIDO artifacts into the packaged app, and the SDK repository lacks an explicit license file. Both findings block an F-Droid eligibility claim until resolved and re-audited. | +| Binary inputs | The Gradle wrapper JAR is the only committed build binary. No AAR/JAR dependency, native `.so`, APK, keystore, certificate, generated backup, or downloaded executable is stored in the repository. | +| CI supply chain | Third-party actions are pinned to immutable commit SHAs. Workflows receive read-only contents permission except the already-scoped tag release job. Signing material is optional GitHub secret input and is never available to pull-request builds. | + +Dependency inventory is reviewed with `./gradlew :app:dependencies` plus APK inspection. A standalone +dependency-vulnerability or secret-scanning product is not made a blocking gate here: no stable, +complete, non-proprietary Android advisory gate is already configured, and adding an unreviewed +scanner would create unactionable failures. Repository history and the complete diff are still +searched for credential formats, keystores, certificates, APKs, local paths, and generated data +before publication. Dependabot or an OSV-based gate can be added separately once ownership and +remediation SLAs are defined. + +## Remediation workflow + +1. Reproduce the gate locally from a clean checkout and record the exact tool/JDK versions. +2. Classify the result as new actionable code, verified pre-existing debt, false positive, or tool + instability. Security-sensitive findings are never dismissed solely because they are old. +3. Fix the smallest safe scope and add a regression test when behavior changed. +4. If a baseline is unavoidable, add only the exact finding with a durable explanation here or in + the relevant policy document. +5. Rerun unit, lint, applicable instrumentation/live lanes, `git diff --check`, and the packaged APK + inspection. Report each lane separately in the PR. diff --git a/fastlane/metadata/android/en-US/changelogs/260602.txt b/fastlane/metadata/android/en-US/changelogs/260602.txt index 09b386b..6d7b62a 100644 --- a/fastlane/metadata/android/en-US/changelogs/260602.txt +++ b/fastlane/metadata/android/en-US/changelogs/260602.txt @@ -1 +1 @@ -Initial Android beta release. +Initial Android alpha release. diff --git a/fastlane/metadata/android/en-US/changelogs/260901.txt b/fastlane/metadata/android/en-US/changelogs/260901.txt new file mode 100644 index 0000000..35537a1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/260901.txt @@ -0,0 +1,2 @@ +Improves Android parity, resilient offline state, durable operations, adaptive navigation, widgets, +network topology, accessibility foundations, localization readiness, and release quality gates. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2533543..fcc8ab4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ coil = "2.7.0" coroutines = "1.9.0" datastore = "1.1.1" glance = "1.2.0" +ktor = "3.0.3" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" @@ -36,6 +37,7 @@ compose-material-icons-extended = { group = "androidx.compose.material", name = coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } # Arcane SDK — resolved from the sibling checkout when present, otherwise from Git. No version is # declared here; the Git source dependency's branch is pinned at the use-site in app/build.gradle.kts. diff --git a/scripts/run-disposable-live-tests.sh b/scripts/run-disposable-live-tests.sh new file mode 100755 index 0000000..157cc93 --- /dev/null +++ b/scripts/run-disposable-live-tests.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This wrapper deliberately has no default server. Live tests must opt in to a disposable target, +# use a unique resource prefix, and provide an idempotent cleanup executable. +: "${ARCANE_E2E_CONFIRM:?Set ARCANE_E2E_CONFIRM=disposable}" +: "${ARCANE_E2E_BASE_URL:?Set the disposable Arcane base URL}" +: "${ARCANE_E2E_USERNAME:?Set the disposable Arcane username}" +: "${ARCANE_E2E_PASSWORD:?Set the disposable Arcane password}" +: "${ARCANE_E2E_RUN_ID:?Set a unique lowercase run ID}" +: "${ARCANE_E2E_CLEANUP:?Set an absolute path to an executable cleanup callback}" + +if [[ "$ARCANE_E2E_CONFIRM" != "disposable" ]]; then + echo "Refusing live tests without ARCANE_E2E_CONFIRM=disposable." >&2 + exit 2 +fi + +case "$ARCANE_E2E_BASE_URL" in + https://127.0.0.1:43553|https://localhost:43553|https://10.0.2.2:43553) ;; + *) + echo "Refusing unapproved live-test target: $ARCANE_E2E_BASE_URL" >&2 + exit 2 + ;; +esac + +if [[ ! "$ARCANE_E2E_RUN_ID" =~ ^[a-z0-9][a-z0-9-]{5,47}$ ]]; then + echo "ARCANE_E2E_RUN_ID must be a unique 6-48 character lowercase identifier." >&2 + exit 2 +fi +ARCANE_E2E_TIMEOUT_SECONDS="${ARCANE_E2E_TIMEOUT_SECONDS:-900}" +if [[ ! "$ARCANE_E2E_TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || + (( ARCANE_E2E_TIMEOUT_SECONDS < 30 || ARCANE_E2E_TIMEOUT_SECONDS > 3600 )); then + echo "ARCANE_E2E_TIMEOUT_SECONDS must be between 30 and 3600." >&2 + exit 2 +fi +if [[ "$ARCANE_E2E_CLEANUP" != /* || ! -x "$ARCANE_E2E_CLEANUP" ]]; then + echo "ARCANE_E2E_CLEANUP must be an absolute executable path." >&2 + exit 2 +fi +if [[ "$#" -eq 0 ]]; then + echo "Pass the bounded live-test command after --." >&2 + exit 2 +fi +if [[ "$1" == "--" ]]; then + shift +fi +if [[ "$#" -eq 0 ]]; then + echo "No live-test command supplied." >&2 + exit 2 +fi + +export ARCANE_E2E_RESOURCE_PREFIX="par-live-${ARCANE_E2E_RUN_ID}" + +cleanup() { + local test_status=$? + local cleanup_status=0 + trap - EXIT INT TERM + "$ARCANE_E2E_CLEANUP" || cleanup_status=$? + if [[ $cleanup_status -ne 0 ]]; then + echo "Disposable live-test cleanup failed." >&2 + exit "$cleanup_status" + fi + exit "$test_status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +curl_args=(--fail --silent --show-error --max-time 10) +if [[ -n "${ARCANE_E2E_CA_CERT:-}" ]]; then + if [[ ! -f "$ARCANE_E2E_CA_CERT" ]]; then + echo "ARCANE_E2E_CA_CERT does not name a readable certificate." >&2 + exit 2 + fi + curl_args+=(--cacert "$ARCANE_E2E_CA_CERT") +fi +curl "${curl_args[@]}" "$ARCANE_E2E_BASE_URL/api/health" >/dev/null + +timeout --signal=TERM --kill-after=30s "${ARCANE_E2E_TIMEOUT_SECONDS}s" "$@"