From 94e972ae03afd3a9fe133ad78a2861fadc4fc36d Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 20:58:45 +0000 Subject: [PATCH 01/10] Complete Android release readiness parity batch --- .github/workflows/android.yml | 58 ++- .gitignore | 1 + README.md | 18 +- app/build.gradle.kts | 12 +- app/lint-baseline.xml | 460 ++++++++++++++++++ .../AuthenticatedRouteInstrumentedTest.kt | 27 + .../android/ExampleInstrumentedTest.kt | 24 - .../android/MainActivitySystemBarTest.kt | 33 ++ .../PinnedItemsStoreInstrumentedTest.kt | 34 ++ .../android/ReleaseReadinessUiTest.kt | 153 ++++++ .../nav/AdaptiveNavigationShellTest.kt | 8 +- app/src/main/AndroidManifest.xml | 1 - .../app/getarcane/android/MainActivity.kt | 8 +- .../android/core/FleetUpdateSummary.kt | 34 ++ .../app/getarcane/android/core/Format.kt | 2 +- .../core/OperationNotificationProjector.kt | 122 +++-- .../getarcane/android/core/OperationStore.kt | 6 +- .../android/core/PinnedItemsStore.kt | 5 +- .../android/core/ProjectDeployPreferences.kt | 5 +- .../android/nav/AdaptiveNavigationShell.kt | 60 ++- .../app/getarcane/android/nav/AppTab.kt | 66 +-- .../app/getarcane/android/nav/TabSwapSheet.kt | 16 +- .../app/getarcane/android/ui/ArcaneApp.kt | 38 +- .../getarcane/android/ui/auth/LoginScreen.kt | 81 +-- .../android/ui/components/DemoBanner.kt | 2 +- .../DestructiveConfirmationDialog.kt | 38 ++ .../android/ui/components/ErrorBanner.kt | 12 +- .../android/ui/components/FormFieldRows.kt | 2 +- .../ui/components/ResourceStatusBadge.kt | 4 +- .../android/ui/components/StaleDataBanner.kt | 4 + .../android/ui/components/StatRing.kt | 19 +- .../android/ui/operations/OperationCenter.kt | 85 +++- .../ui/screens/DashboardPinnedSection.kt | 49 +- .../android/ui/screens/DashboardScreen.kt | 94 +++- .../ui/screens/EnvironmentDashboardCard.kt | 85 +++- .../ui/screens/UpdateAllEnvironmentsDialog.kt | 52 +- .../containers/ContainerActionPolicy.kt | 62 +-- .../containers/ContainerDetailScreen.kt | 34 +- .../containers/ContainerStatsScreen.kt | 3 +- .../environments/EnvironmentListScreen.kt | 20 +- .../images/AllVulnerabilitiesScreen.kt | 4 +- .../images/ImageVulnerabilitiesScreen.kt | 4 +- .../ui/screens/projects/ComposeFileScreen.kt | 8 +- .../screens/projects/ProjectDetailScreen.kt | 21 +- .../settings/PermissionResourceVisuals.kt | 4 +- .../ui/screens/settings/SettingsScreen.kt | 10 +- .../app/getarcane/android/ui/theme/Color.kt | 18 + .../android/widget/FleetStatusWidget.kt | 4 +- app/src/main/res/values/colors.xml | 9 +- app/src/main/res/values/strings.xml | 222 +++++++++ .../android/core/OperationStorePolicyTest.kt | 15 +- .../ui/screens/DashboardActivityBadgeTest.kt | 12 - .../ui/screens/EnvironmentCardActionsTest.kt | 30 +- .../ui/screens/UpdateAllEnvironmentsTest.kt | 52 +- .../containers/ContainerActionPolicyTest.kt | 14 +- docs/accessibility-and-localization.md | 92 ++++ docs/fdroid-release-preparation.md | 145 ++++++ docs/ios-parity-task-list.md | 221 ++++++--- docs/release-readiness.md | 170 +++++++ docs/testing-and-quality-gates.md | 92 ++++ .../android/en-US/changelogs/260602.txt | 2 +- .../android/en-US/changelogs/260901.txt | 2 + gradle/libs.versions.toml | 2 + scripts/run-disposable-live-tests.sh | 79 +++ 64 files changed, 2557 insertions(+), 512 deletions(-) create mode 100644 app/lint-baseline.xml create mode 100644 app/src/androidTest/java/app/getarcane/android/AuthenticatedRouteInstrumentedTest.kt delete mode 100644 app/src/androidTest/java/app/getarcane/android/ExampleInstrumentedTest.kt create mode 100644 app/src/androidTest/java/app/getarcane/android/MainActivitySystemBarTest.kt create mode 100644 app/src/androidTest/java/app/getarcane/android/PinnedItemsStoreInstrumentedTest.kt create mode 100644 app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt create mode 100644 app/src/main/kotlin/app/getarcane/android/core/FleetUpdateSummary.kt create mode 100644 app/src/main/kotlin/app/getarcane/android/ui/components/DestructiveConfirmationDialog.kt create mode 100644 docs/accessibility-and-localization.md create mode 100644 docs/fdroid-release-preparation.md create mode 100644 docs/release-readiness.md create mode 100644 docs/testing-and-quality-gates.md create mode 100644 fastlane/metadata/android/en-US/changelogs/260901.txt create mode 100755 scripts/run-disposable-live-tests.sh diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 8aaad25..7d3ca87 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -6,6 +6,7 @@ on: - main - "feature/**" - "fix/**" + - "parity/**" tags: - "v*" pull_request: @@ -29,16 +30,16 @@ jobs: steps: - name: Check out source - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: distribution: temurin java-version: "21" - name: Set up Android SDK - uses: android-actions/setup-android@v3 + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 with: packages: platform-tools @@ -46,7 +47,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@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 - name: Make Gradle wrapper executable run: chmod +x ./gradlew @@ -58,11 +59,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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: Arcane-debug-apk path: app/build/outputs/apk/debug/*.apk @@ -101,7 +105,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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: Arcane-release-signed-apk path: Arcane-${{ github.ref_name }}.apk @@ -111,6 +115,44 @@ 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 + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Set up JDK + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-35" "build-tools;35.0.0" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - 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 -camera-back none + script: ./gradlew --no-daemon :app:connectedDebugAndroidTest + publish-release: name: Publish signed release runs-on: ubuntu-latest @@ -121,13 +163,13 @@ jobs: steps: - name: Download signed release APK - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 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@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 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..32b89a1 --- /dev/null +++ b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt @@ -0,0 +1,153 @@ +package app.getarcane.android + +import android.view.KeyEvent +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.platform.app.InstrumentationRegistry +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() + InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(KeyEvent.KEYCODE_BACK) + 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/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..569b9e4 --- /dev/null +++ b/docs/fdroid-release-preparation.md @@ -0,0 +1,145 @@ +# 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. + +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..02130c2 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,42 @@ 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. `:app:assembleRelease` produced an unsigned APK, but eligibility is 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 +1503,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 +1567,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..0bc0a3b --- /dev/null +++ b/docs/release-readiness.md @@ -0,0 +1,170 @@ +# 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 sibling-SDK build, remote-SDK fallback 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. 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..f80ef53 --- /dev/null +++ b/docs/testing-and-quality-gates.md @@ -0,0 +1,92 @@ +# 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 +emulator with animations disabled: + +```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" "$@" From 67d902307f3f52c533e584b2629ae30d0cfc607c Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:00:27 +0000 Subject: [PATCH 02/10] Record clean remote SDK release validation --- docs/fdroid-release-preparation.md | 6 ++++++ docs/ios-parity-task-list.md | 11 +++++++---- docs/release-readiness.md | 13 ++++++++----- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/fdroid-release-preparation.md b/docs/fdroid-release-preparation.md index 569b9e4..1708c30 100644 --- a/docs/fdroid-release-preparation.md +++ b/docs/fdroid-release-preparation.md @@ -68,6 +68,12 @@ for `https://github.com/getarcaneapp/libarcane-kotlin.git` at an exact full comm `../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 diff --git a/docs/ios-parity-task-list.md b/docs/ios-parity-task-list.md index 02130c2..bd98c88 100644 --- a/docs/ios-parity-task-list.md +++ b/docs/ios-parity-task-list.md @@ -1336,10 +1336,13 @@ The standard checks are: 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. `:app:assembleRelease` produced an unsigned APK, but eligibility is 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. + 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** diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 0bc0a3b..d313260 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -43,7 +43,7 @@ failures/limitations, and cleanup. Required evidence is: 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 sibling-SDK build, remote-SDK fallback build, unsigned release build, packaged +- 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 @@ -121,10 +121,13 @@ 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. 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. + 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 From 79e05cd382c13968760b963f6fdf97debbed203a Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:01:36 +0000 Subject: [PATCH 03/10] Fix emulator SDK setup in CI --- .github/workflows/android.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7d3ca87..abecdf6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -133,6 +133,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + with: + packages: platform-tools - name: Install Android SDK packages run: sdkmanager "platforms;android-35" "build-tools;35.0.0" From 08b9ec707d5208f3a8dd9e58db0dc1d43c1cc92c Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:08:40 +0000 Subject: [PATCH 04/10] Refresh pinned CI actions --- .github/workflows/android.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index abecdf6..c361fa6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -30,16 +30,16 @@ jobs: steps: - name: Check out source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up JDK - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "21" - name: Set up Android SDK - uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 with: packages: platform-tools @@ -47,7 +47,7 @@ jobs: run: sdkmanager "platforms;android-35" "build-tools;35.0.0" - name: Set up Gradle - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5 - name: Make Gradle wrapper executable run: chmod +x ./gradlew @@ -66,7 +66,7 @@ jobs: run: ./gradlew --no-daemon :app:assembleDebug - name: Upload debug APK - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: Arcane-debug-apk path: app/build/outputs/apk/debug/*.apk @@ -105,7 +105,7 @@ jobs: - name: Upload signed release APK if: startsWith(github.ref, 'refs/tags/v') && env.HAS_RELEASE_SIGNING == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 with: name: Arcane-release-signed-apk path: Arcane-${{ github.ref_name }}.apk @@ -123,16 +123,16 @@ jobs: steps: - name: Check out source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - name: Set up JDK - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "21" - name: Set up Android SDK - uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 with: packages: platform-tools @@ -140,7 +140,7 @@ jobs: run: sdkmanager "platforms;android-35" "build-tools;35.0.0" - name: Set up Gradle - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + uses: gradle/actions/setup-gradle@0723195856401067f7a2779048b490ace7a47d7c # v5 - name: Make Gradle wrapper executable run: chmod +x ./gradlew @@ -165,13 +165,13 @@ jobs: steps: - name: Download signed release APK - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 with: name: Arcane-release-signed-apk path: . - name: Attach signed APK to GitHub Release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: files: Arcane-${{ github.ref_name }}.apk fail_on_unmatched_files: true From 0b2d5cd01ca280d3b59ef1dfb059cebbb48f589d Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:21:01 +0000 Subject: [PATCH 05/10] Use scoped Back input in Compose test --- .../java/app/getarcane/android/ReleaseReadinessUiTest.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt index 32b89a1..dffd4a5 100644 --- a/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt +++ b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt @@ -1,13 +1,14 @@ package app.getarcane.android -import android.view.KeyEvent 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.input.key.Key import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsSelected @@ -15,8 +16,9 @@ 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.performKeyInput import androidx.compose.ui.test.performClick -import androidx.test.platform.app.InstrumentationRegistry +import androidx.compose.ui.test.pressKey import app.getarcane.android.core.AuthStatus import app.getarcane.android.core.OperationKind import app.getarcane.android.core.OperationRecord @@ -33,6 +35,7 @@ import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +@OptIn(ExperimentalTestApi::class) class ReleaseReadinessUiTest { @get:Rule val composeRule = createComposeRule() @@ -78,7 +81,7 @@ class ReleaseReadinessUiTest { } composeRule.onNodeWithText("Delete test container?").assertIsDisplayed() - InstrumentationRegistry.getInstrumentation().sendKeyDownUpSync(KeyEvent.KEYCODE_BACK) + composeRule.onNodeWithText("Delete test container?").performKeyInput { pressKey(Key.Back) } composeRule.runOnIdle { assertEquals(0, confirmations) assertEquals(1, dismissals) From 5532a7ce630f0fd11938e55720be9d8719ab3727 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:30:26 +0000 Subject: [PATCH 06/10] Refresh artifact actions for Node 24 --- .github/workflows/android.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c361fa6..a577e39 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -66,7 +66,7 @@ jobs: run: ./gradlew --no-daemon :app:assembleDebug - name: Upload debug APK - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Arcane-debug-apk path: app/build/outputs/apk/debug/*.apk @@ -105,7 +105,7 @@ jobs: - name: Upload signed release APK if: startsWith(github.ref, 'refs/tags/v') && env.HAS_RELEASE_SIGNING == 'true' - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: Arcane-release-signed-apk path: Arcane-${{ github.ref_name }}.apk @@ -165,7 +165,7 @@ jobs: steps: - name: Download signed release APK - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Arcane-release-signed-apk path: . From f8fad79fd50b7fa8504c8eb94f9140ab8cf99d28 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:38:16 +0000 Subject: [PATCH 07/10] Stabilize the CI emulator lane --- .github/workflows/android.yml | 13 ++++++++++++- docs/testing-and-quality-gates.md | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index a577e39..bd20374 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -17,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 @@ -117,6 +121,7 @@ jobs: 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 @@ -145,6 +150,12 @@ jobs: - 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: @@ -152,7 +163,7 @@ jobs: arch: x86_64 profile: pixel_2 disable-animations: true - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-metrics -camera-back none script: ./gradlew --no-daemon :app:connectedDebugAndroidTest publish-release: diff --git a/docs/testing-and-quality-gates.md b/docs/testing-and-quality-gates.md index f80ef53..946ec29 100644 --- a/docs/testing-and-quality-gates.md +++ b/docs/testing-and-quality-gates.md @@ -16,7 +16,9 @@ Use JDK 21 with the repository's Gradle wrapper. The required pull-request basel 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 -emulator with animations disabled: +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 From 9f0a9857f92ca5053791e73905ecf4688dc91253 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:46:19 +0000 Subject: [PATCH 08/10] Exercise dialog back through Espresso --- .../java/app/getarcane/android/ReleaseReadinessUiTest.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt index dffd4a5..bace121 100644 --- a/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt +++ b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt @@ -6,9 +6,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsSelected @@ -16,9 +14,8 @@ 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.performKeyInput import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.pressKey +import androidx.test.espresso.Espresso.pressBack import app.getarcane.android.core.AuthStatus import app.getarcane.android.core.OperationKind import app.getarcane.android.core.OperationRecord @@ -35,7 +32,6 @@ import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test -@OptIn(ExperimentalTestApi::class) class ReleaseReadinessUiTest { @get:Rule val composeRule = createComposeRule() @@ -81,7 +77,7 @@ class ReleaseReadinessUiTest { } composeRule.onNodeWithText("Delete test container?").assertIsDisplayed() - composeRule.onNodeWithText("Delete test container?").performKeyInput { pressKey(Key.Back) } + pressBack() composeRule.runOnIdle { assertEquals(0, confirmations) assertEquals(1, dismissals) From 888bbce36168031693a9c2c873bd2ac8dc744fb4 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:50:42 +0000 Subject: [PATCH 09/10] Await preference test scope cleanup --- .../android/core/PrefsAppearanceTest.kt | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) 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() } } From 4097bb90622697117b1a311c0d0c7f536bc4e7c6 Mon Sep 17 00:00:00 2001 From: Michael Kaltner Date: Wed, 16 Sep 2026 21:59:51 +0000 Subject: [PATCH 10/10] Target the dialog root for Back input --- .../java/app/getarcane/android/ReleaseReadinessUiTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt index bace121..b90f6da 100644 --- a/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt +++ b/app/src/androidTest/java/app/getarcane/android/ReleaseReadinessUiTest.kt @@ -15,7 +15,10 @@ 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.pressBack +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 @@ -77,7 +80,7 @@ class ReleaseReadinessUiTest { } composeRule.onNodeWithText("Delete test container?").assertIsDisplayed() - pressBack() + onView(isRoot()).inRoot(isDialog()).perform(pressBack()) composeRule.runOnIdle { assertEquals(0, confirmations) assertEquals(1, dismissals)