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