diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 60ab8eb..904814a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,4 +24,4 @@ updates: android: patterns: ["com.android*", "androidx.activity*", "androidx.test*"] contracts: - patterns: ["io.github.arcforges*", "io.grpc*"] + patterns: ["io.github.arcforges*", "com.connectrpc*", "com.squareup.okhttp3*", "com.squareup.okio*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65c5a85..51bcf6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,7 @@ jobs: keytool -genkeypair -keystore "$RUNNER_TEMP/test.jks" -storepass android -keypass android -alias test -keyalg RSA -keysize 3072 -validity 2 -dname 'CN=Disposable CI test' "$ANDROID_HOME/build-tools/37.0.0/zipalign" -P 16 -f 4 artifacts/candidate/app-release-unsigned.apk "$RUNNER_TEMP/aligned.apk" "$ANDROID_HOME/build-tools/37.0.0/apksigner" sign --ks "$RUNNER_TEMP/test.jks" --ks-pass pass:android --out artifacts/test-release.apk "$RUNNER_TEMP/aligned.apk" - - name: Exercise UI, recreation and the minified release APK + - name: Exercise Android UI, live gRPC-Web and the minified release APK uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 with: api-level: 36 @@ -135,7 +135,11 @@ jobs: adb install artifacts/candidate/app-debug.apk adb install artifacts/candidate/app-debug-androidTest.apk adb shell am instrument -w io.github.arcforges.mobile.debug.test/androidx.test.runner.AndroidJUnitRunner | tee artifacts/instrumentation.txt + mkdir -p artifacts/device + adb logcat -d > artifacts/device/logcat.txt + adb shell run-as io.github.arcforges.mobile.debug cat files/cloud-hello-evidence.json > artifacts/device/cloud-hello.json grep -E '^OK \([1-9][0-9]* tests?\)' artifacts/instrumentation.txt + grep '^CLOUD_HELLO_VERIFIED ' artifacts/instrumentation.txt python eng/device-smoke.py artifacts/test-release.apk --serial emulator-5554 - name: Upload device evidence if: always() @@ -210,12 +214,12 @@ jobs: VERSION_NAME: ${{ needs.build.outputs.version_name }} run: | cat > "$RUNNER_TEMP/release-notes.md" <; + public static ** getDefaultInstance(); } diff --git a/app/src/androidTest/kotlin/io/github/arcforges/mobile/CloudHelloIntegrationTest.kt b/app/src/androidTest/kotlin/io/github/arcforges/mobile/CloudHelloIntegrationTest.kt new file mode 100644 index 0000000..5d89c3e --- /dev/null +++ b/app/src/androidTest/kotlin/io/github/arcforges/mobile/CloudHelloIntegrationTest.kt @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.arcforges.mobile + +import android.os.Bundle +import android.os.SystemClock +import androidx.test.platform.app.InstrumentationRegistry +import com.connectrpc.Code +import com.connectrpc.ConnectException +import java.io.File +import java.util.Collections +import kotlinx.coroutines.runBlocking +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Readiness polls are separate from RPCs; application requests are never retried. */ +internal fun cloudHealth(): JSONObject { + val http = CloudHelloClient.transport() + val until = SystemClock.elapsedRealtime() + 60000 + var last = "no response" + try { + while (SystemClock.elapsedRealtime() < until) { + try { + http + .newCall(Request.Builder().url("${CloudHelloClient.BASE_URL}/healthz").build()) + .execute() + .use { response -> + last = "HTTP ${response.code}" + if (response.isSuccessful) { + val health = JSONObject(response.body.string()) + check(health.getString("service") == "arcforges-cloud") + check(health.getBoolean("nativeAot")) + check(health.getString("revision").matches(Regex("[0-9a-f]{40}"))) + check( + response.header("x-arcforges-worker-revision") == + health.getString("revision") + ) + return health + } + } + } catch (failure: Exception) { + last = failure.toString() + } + Thread.sleep(3000) + } + error("Cloud readiness failed: $last") + } finally { + http.connectionPool.evictAll() + http.dispatcher.executorService.shutdown() + } +} + +class CloudHelloIntegrationTest { + @Test + fun androidSdkCallsDeployedAotThroughWorker() = runBlocking { + val health = cloudHealth() + val requests = Collections.synchronizedList(mutableListOf()) + var forcedTimeout: String? = null + val transport = + CloudHelloClient.transport() + .newBuilder() + .addInterceptor { chain -> + val original = chain.request() + check(original.method == "POST") + check( + original.url.toString() == + "${CloudHelloClient.BASE_URL}/arcforges.hello.v1.HelloService/SayHello" + ) + check(original.body!!.contentType().toString() == "application/grpc-web+proto") + check(original.header("grpc-timeout")!!.matches(Regex("[0-9]{1,8}[HMSmun]"))) + val outgoing = + forcedTimeout?.let { + original.newBuilder().header("grpc-timeout", it).build() + } ?: original + chain.proceed(outgoing).also { response -> + check(response.code == 200) + val type = + response.header("Content-Type")!!.substringBefore(';').lowercase() + check(type in listOf("application/grpc-web", "application/grpc-web+proto")) + if (forcedTimeout == null) + check( + response.header("x-arcforges-worker-revision") == + health.getString("revision") + ) + requests.add( + JSONObject() + .put("url", outgoing.url.toString()) + .put("requestContentType", outgoing.body!!.contentType().toString()) + .put("sdkTimeout", original.header("grpc-timeout")) + .put("wireTimeout", outgoing.header("grpc-timeout")) + .put("responseContentType", type) + .put("httpStatus", response.code) + ) + } + } + .build() + val codes = mutableListOf() + CloudHelloClient(http = transport).use { client -> + for (name in listOf("Android", "涓栫晫 馃憢", " \t ", "x".repeat(256))) { + assertEquals("Hello, $name!", client.sayHello(name)) + codes.add("OK") + } + suspend fun failure(name: String, expected: Code) { + try { + client.sayHello(name) + error("Expected $expected") + } catch (error: ConnectException) { + assertEquals(expected, error.code) + codes.add(error.code.name) + } + } + failure("", Code.INVALID_ARGUMENT) + failure("x".repeat(257), Code.RESOURCE_EXHAUSTED) + forcedTimeout = "0m" + failure("Expired", Code.DEADLINE_EXCEEDED) + forcedTimeout = "invalid" + failure("Malformed", Code.INVALID_ARGUMENT) + } + assertEquals(8, requests.size) + assertTrue(health.getBoolean("nativeAot")) + val evidence = + JSONObject() + .put("contractsVersion", BuildConfig.CONTRACTS_VERSION) + .put("health", health) + .put("requests", JSONArray(requests)) + .put("grpcCodes", JSONArray(codes)) + .put("deviceSdk", android.os.Build.VERSION.SDK_INT) + val instrumentation = InstrumentationRegistry.getInstrumentation() + File(instrumentation.targetContext.filesDir, "cloud-hello-evidence.json") + .writeText(evidence.toString(2)) + instrumentation.sendStatus( + 0, + Bundle().apply { + putString( + "stream", + "\nCLOUD_HELLO_VERIFIED ${health.getString("revision")} Contracts ${BuildConfig.CONTRACTS_VERSION}\n", + ) + }, + ) + } +} diff --git a/app/src/androidTest/kotlin/io/github/arcforges/mobile/GreetingScreenTest.kt b/app/src/androidTest/kotlin/io/github/arcforges/mobile/GreetingScreenTest.kt index 9bc81c7..ae3fc47 100644 --- a/app/src/androidTest/kotlin/io/github/arcforges/mobile/GreetingScreenTest.kt +++ b/app/src/androidTest/kotlin/io/github/arcforges/mobile/GreetingScreenTest.kt @@ -1,12 +1,25 @@ // SPDX-License-Identifier: Apache-2.0 package io.github.arcforges.mobile +import androidx.activity.compose.setContent +import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.printToString +import io.github.arcforges.mobile.shared.ArcForgesApp +import io.github.arcforges.mobile.shared.GreetingFailure +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -15,18 +28,88 @@ class GreetingScreenTest { @Test fun greetsAndRestoresAcrossActivityRecreation() { - compose.onNodeWithTag("greeting").assertTextEquals("Hello, World!") + cloudHealth() + compose.onNodeWithTag("greeting").assertTextEquals("Ready to connect.") compose.onNodeWithTag("name").performTextReplacement("Android") - compose.onNodeWithTag("say-hello").performClick() - compose.onNodeWithTag("greeting").assertTextEquals("Hello, Android!") + compose.onNodeWithTag("say-hello").performScrollTo().performClick() + try { + compose.waitUntil(15000) { + compose.onAllNodes(hasText("Hello, Android!")).fetchSemanticsNodes().isNotEmpty() || + compose.onAllNodes(hasTestTag("error")).fetchSemanticsNodes().isNotEmpty() + } + compose.onNodeWithTag("error").assertDoesNotExist() + compose.onNodeWithTag("greeting").assertTextEquals("Hello, Android!") + } catch (failure: Throwable) { + throw AssertionError("Cloud UI state:\n${compose.onRoot().printToString()}", failure) + } compose.activityRule.scenario.recreate() compose.onNodeWithTag("greeting").assertTextEquals("Hello, Android!") - compose.onNodeWithTag("name").assertTextEquals("Your name", "Android") + compose.onNodeWithTag("name").assertTextContains("Android") } @Test fun emptyNameCannotBeSubmitted() { compose.onNodeWithTag("name").performTextReplacement("") compose.onNodeWithTag("say-hello").assertIsNotEnabled() + compose.onNodeWithTag("name").performTextReplacement("x".repeat(257)) + compose.onNodeWithTag("say-hello").assertIsNotEnabled() + } + + @Test + fun pendingRequestDisablesDuplicateSubmissionAndFailureAllowsManualRetry() { + val result = CompletableDeferred() + var calls = 0 + compose.activityRule.scenario.onActivity { activity -> + activity.setContent { + ArcForgesApp( + greet = { + calls++ + if (calls == 1) result.await() else "Hello, World!" + }, + initialMessage = "Ready to connect.", + ) + } + } + compose.onNodeWithTag("say-hello").performClick() + compose.onNodeWithTag("greeting").assertTextEquals("Connecting...") + compose.onNodeWithTag("say-hello").assertIsNotEnabled() + compose.onNodeWithTag("name").assertIsNotEnabled() + compose.runOnIdle { result.completeExceptionally(GreetingFailure("Cloud is unavailable.")) } + compose.onNodeWithTag("error").assertTextEquals("Cloud is unavailable.") + compose.onNodeWithTag("say-hello").assertIsEnabled().performClick() + compose.onNodeWithTag("greeting").assertTextEquals("Hello, World!") + compose.onNodeWithTag("error").assertDoesNotExist() + compose.runOnIdle { assertEquals(2, calls) } + } + + @Test + fun recreationCancelsPendingWorkWithoutReplayingIt() { + val pending = CompletableDeferred() + var canceled = false + var calls = 0 + compose.activityRule.scenario.onActivity { activity -> + activity.setContent { + ArcForgesApp( + greet = { + calls++ + try { + pending.await() + } finally { + canceled = true + } + }, + initialMessage = "Ready to connect.", + ) + } + } + compose.onNodeWithTag("say-hello").performClick() + compose.onNodeWithTag("greeting").assertTextEquals("Connecting...") + compose.activityRule.scenario.recreate() + compose.onNodeWithTag("greeting").assertTextEquals("Ready to connect.") + compose.onNodeWithTag("say-hello").assertIsEnabled() + compose.runOnIdle { + assertTrue(canceled) + assertEquals(1, calls) + } } } diff --git a/app/src/main/kotlin/io/github/arcforges/mobile/CloudHelloClient.kt b/app/src/main/kotlin/io/github/arcforges/mobile/CloudHelloClient.kt new file mode 100644 index 0000000..cb08f5a --- /dev/null +++ b/app/src/main/kotlin/io/github/arcforges/mobile/CloudHelloClient.kt @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.arcforges.mobile + +import com.connectrpc.Code +import com.connectrpc.ConnectException +import com.connectrpc.ProtocolClientConfig +import com.connectrpc.extensions.GoogleJavaLiteProtobufStrategy +import com.connectrpc.getOrThrow +import com.connectrpc.impl.ProtocolClient +import com.connectrpc.okhttp.ConnectOkHttpClient +import com.connectrpc.protocols.NetworkProtocol +import io.github.arcforges.contracts.hello.v1.HelloServiceClient +import io.github.arcforges.contracts.hello.v1.sayHelloRequest +import io.github.arcforges.mobile.shared.GreetingFailure +import java.net.URI +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient + +/** Activity-owned transport. Recomposition never creates a connection or sends an RPC. */ +internal class CloudHelloClient( + baseUrl: String = BASE_URL, + private val http: OkHttpClient = transport(), + deadline: Duration = 5.seconds, +) : AutoCloseable { + private val closed = AtomicBoolean(false) + + init { + val endpoint = URI(baseUrl) + require( + endpoint.rawPath == "/api" && endpoint.rawQuery == null && endpoint.rawFragment == null + ) + require(endpoint.userInfo == null) + require( + endpoint.scheme == "https" || + (endpoint.scheme == "http" && endpoint.host == "127.0.0.1") + ) + require(deadline.isPositive() && deadline <= 5.seconds) + } + + private val service = + HelloServiceClient( + ProtocolClient( + httpClient = ConnectOkHttpClient(http), + config = + ProtocolClientConfig( + host = baseUrl, + serializationStrategy = GoogleJavaLiteProtobufStrategy(), + networkProtocol = NetworkProtocol.GRPC_WEB, + ioCoroutineContext = Dispatchers.IO, + timeoutOracle = { deadline }, + ), + ) + ) + + suspend fun sayHello(name: String): String = + service.sayHello(sayHelloRequest { this.name = name }).getOrThrow().message + + suspend fun greet(name: String): String = + try { + sayHello(name) + } catch (failure: ConnectException) { + throw GreetingFailure( + when (failure.code) { + Code.INVALID_ARGUMENT -> "Cloud rejected this name. Check it and try again." + Code.RESOURCE_EXHAUSTED -> "Cloud's request limit was reached. Try again later." + Code.DEADLINE_EXCEEDED -> "Cloud did not respond in time. Try again." + Code.CANCELED -> "The request was canceled. Try again." + else -> "Could not reach Cloud. Check your connection and try again." + } + ) + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + // TLS close_notify can perform I/O. Activity destruction must never close sockets + // on Android's main thread, or weaken StrictMode to allow it. + val executor = http.dispatcher.executorService + executor.execute { + try { + http.dispatcher.cancelAll() + http.connectionPool.evictAll() + } finally { + executor.shutdown() + } + } + } + + companion object { + const val BASE_URL = "https://arcforges.com/api" + + fun transport(): OkHttpClient = + OkHttpClient.Builder() + .callTimeout(10, TimeUnit.SECONDS) + .retryOnConnectionFailure(false) + .followRedirects(false) + .followSslRedirects(false) + .build() + } +} diff --git a/app/src/main/kotlin/io/github/arcforges/mobile/HelloContracts.kt b/app/src/main/kotlin/io/github/arcforges/mobile/HelloContracts.kt deleted file mode 100644 index bb24d93..0000000 --- a/app/src/main/kotlin/io/github/arcforges/mobile/HelloContracts.kt +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -package io.github.arcforges.mobile - -import io.github.arcforges.contracts.hello.v1.HelloServiceGrpcKt -import io.github.arcforges.contracts.hello.v1.SayHelloRequest -import io.github.arcforges.contracts.hello.v1.SayHelloResponse -import io.github.arcforges.contracts.hello.v1.sayHelloRequest -import io.github.arcforges.mobile.shared.hello -import io.grpc.Channel -import java.util.concurrent.TimeUnit - -/** Offline sample using the published protobuf types, including the minified Android runtime. */ -internal fun localContractGreeting(name: String): String { - val request = sayHelloRequest { this.name = name } - val decoded = SayHelloRequest.parseFrom(request.toByteArray()) - val response = SayHelloResponse.newBuilder().setMessage(hello(decoded.name)).build() - return SayHelloResponse.parseFrom(response.toByteArray()).message -} - -/** Native gRPC adapter. The caller owns the channel and supplies its TLS endpoint and lifecycle. */ -internal class HelloClient(channel: Channel) { - private val stub = HelloServiceGrpcKt.HelloServiceCoroutineStub(channel) - - suspend fun sayHello(name: String): String = - stub - .withDeadlineAfter(5, TimeUnit.SECONDS) - .sayHello(sayHelloRequest { this.name = name }) - .message -} diff --git a/app/src/main/kotlin/io/github/arcforges/mobile/MainActivity.kt b/app/src/main/kotlin/io/github/arcforges/mobile/MainActivity.kt index 2364ae5..02c01b5 100644 --- a/app/src/main/kotlin/io/github/arcforges/mobile/MainActivity.kt +++ b/app/src/main/kotlin/io/github/arcforges/mobile/MainActivity.kt @@ -8,9 +8,22 @@ import androidx.activity.enableEdgeToEdge import io.github.arcforges.mobile.shared.ArcForgesApp class MainActivity : ComponentActivity() { + private val cloud = CloudHelloClient() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - setContent { ArcForgesApp(greet = ::localContractGreeting) } + setContent { + ArcForgesApp( + greet = cloud::greet, + initialMessage = "Ready to connect.", + serviceLabel = "Cloud Hello 路 arcforges.com", + ) + } + } + + override fun onDestroy() { + cloud.close() + super.onDestroy() } } diff --git a/app/src/test/kotlin/io/github/arcforges/mobile/CloudHelloClientTest.kt b/app/src/test/kotlin/io/github/arcforges/mobile/CloudHelloClientTest.kt new file mode 100644 index 0000000..7e3e312 --- /dev/null +++ b/app/src/test/kotlin/io/github/arcforges/mobile/CloudHelloClientTest.kt @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +package io.github.arcforges.mobile + +import com.connectrpc.Code +import com.connectrpc.ConnectException +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import io.github.arcforges.contracts.hello.v1.SayHelloRequest +import io.github.arcforges.contracts.hello.v1.SayHelloResponse +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CloudHelloClientTest { + private class Fixture(val handle: (HttpExchange) -> Unit) : AutoCloseable { + val calls = AtomicInteger() + val arrived = CountDownLatch(1) + val release = CountDownLatch(1) + val server = + HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply { + createContext("/") { exchange -> + calls.incrementAndGet() + arrived.countDown() + try { + handle(exchange) + } finally { + exchange.close() + } + } + start() + } + val url + get() = "http://127.0.0.1:${server.address.port}/api" + + override fun close() { + release.countDown() + server.stop(0) + } + } + + private fun frame(flag: Byte, bytes: ByteArray): ByteArray = + ByteBuffer.allocate(bytes.size + 5).put(flag).putInt(bytes.size).put(bytes).array() + + private fun reply(exchange: HttpExchange, status: Int, message: String? = null) { + val data = + message?.let { + frame(0, SayHelloResponse.newBuilder().setMessage(it).build().toByteArray()) + } ?: byteArrayOf() + val body = data + frame(128.toByte(), "grpc-status: $status\r\n".toByteArray()) + // ASP.NET can legitimately omit +proto on its binary protobuf response. + exchange.responseHeaders.add("Content-Type", "application/grpc-web") + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.write(body) + } + + private suspend fun expect(code: Code, operation: suspend () -> Unit) { + try { + operation() + error("Expected $code") + } catch (failure: ConnectException) { + assertEquals(code, failure.code) + } + } + + @Test + fun publishedSdkUsesBinaryGrpcWebAndOneApiPrefix() = runBlocking { + Fixture { exchange -> + assertEquals("/api/arcforges.hello.v1.HelloService/SayHello", exchange.requestURI.path) + assertEquals("POST", exchange.requestMethod) + assertEquals( + "application/grpc-web+proto", + exchange.requestHeaders.getFirst("Content-Type"), + ) + assertTrue( + exchange.requestHeaders + .getFirst("grpc-timeout") + .matches(Regex("[0-9]{1,8}[HMSmun]")) + ) + val bytes = exchange.requestBody.readAllBytes() + assertEquals(0, bytes[0].toInt()) + assertEquals(bytes.size - 5, ByteBuffer.wrap(bytes, 1, 4).int) + val name = SayHelloRequest.parseFrom(bytes.copyOfRange(5, bytes.size)).name + reply(exchange, 0, "Hello, $name!") + } + .use { fixture -> + CloudHelloClient(fixture.url).use { client -> + assertEquals("Hello, 涓栫晫 馃憢 !", client.sayHello(" 涓栫晫 馃憢 ")) + } + assertEquals(1, fixture.calls.get()) + } + } + + @Test + fun sdkPreservesApplicationAndHttpErrorsWithoutRetry() = runBlocking { + for ((status, expected) in + listOf( + 3 to Code.INVALID_ARGUMENT, + 8 to Code.RESOURCE_EXHAUSTED, + 404 to Code.UNIMPLEMENTED, + )) { + Fixture { exchange -> + exchange.requestBody.readAllBytes() + if (status == 404) exchange.sendResponseHeaders(404, -1) + else reply(exchange, status) + } + .use { fixture -> + CloudHelloClient(fixture.url).use { client -> + expect(expected) { client.sayHello("Failure") } + } + assertEquals(1, fixture.calls.get()) + } + } + } + + @Test + fun deadlineBoundsHeadersAndPartialBodyWithoutRetry() = runBlocking { + for (partial in listOf(false, true)) { + lateinit var fixture: Fixture + fixture = Fixture { exchange -> + exchange.requestBody.readAllBytes() + if (partial) { + exchange.responseHeaders.add("Content-Type", "application/grpc-web+proto") + exchange.sendResponseHeaders(200, 0) + exchange.responseBody.write(0) + exchange.responseBody.flush() + } + fixture.release.await(5, TimeUnit.SECONDS) + } + fixture.use { + CloudHelloClient(fixture.url, deadline = 500.milliseconds).use { client -> + withTimeout(3000) { + expect(Code.DEADLINE_EXCEEDED) { client.sayHello("Deadline") } + } + } + assertEquals(1, fixture.calls.get()) + } + } + } + + @Test + fun coroutineCancellationStopsTheHttpCall() = runBlocking { + lateinit var fixture: Fixture + fixture = Fixture { exchange -> + exchange.requestBody.readAllBytes() + fixture.release.await(5, TimeUnit.SECONDS) + } + fixture.use { + val http = CloudHelloClient.transport() + CloudHelloClient(fixture.url, http).use { client -> + val request = launch { client.sayHello("Cancel") } + assertTrue( + withContext(Dispatchers.IO) { fixture.arrived.await(3, TimeUnit.SECONDS) } + ) + request.cancelAndJoin() + withTimeout(2000) { while (http.dispatcher.runningCallsCount() != 0) delay(10) } + assertEquals(1, fixture.calls.get()) + } + } + } +} diff --git a/app/src/test/kotlin/io/github/arcforges/mobile/HelloContractsTest.kt b/app/src/test/kotlin/io/github/arcforges/mobile/HelloContractsTest.kt deleted file mode 100644 index 299214b..0000000 --- a/app/src/test/kotlin/io/github/arcforges/mobile/HelloContractsTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -package io.github.arcforges.mobile - -import io.github.arcforges.contracts.hello.v1.HelloServiceGrpcKt -import io.github.arcforges.contracts.hello.v1.SayHelloRequest -import io.github.arcforges.contracts.hello.v1.SayHelloResponse -import io.grpc.Status -import io.grpc.StatusException -import io.grpc.inprocess.InProcessChannelBuilder -import io.grpc.inprocess.InProcessServerBuilder -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Test - -class HelloContractsTest { - @Test - fun publishedContractPreservesUnicode() { - assertEquals("Hello, 涓栫晫 馃憢 !", localContractGreeting(" 涓栫晫 馃憢 ")) - } - - @Test - fun publishedCoroutineClientCallsServiceAndPreservesStatus() = runBlocking { - val address = InProcessServerBuilder.generateName() - val service = - object : HelloServiceGrpcKt.HelloServiceCoroutineImplBase() { - override suspend fun sayHello(request: SayHelloRequest): SayHelloResponse { - if (request.name.isEmpty()) throw Status.INVALID_ARGUMENT.asException() - return SayHelloResponse.newBuilder() - .setMessage("Hello, ${request.name}!") - .build() - } - } - val server = - InProcessServerBuilder.forName(address) - .directExecutor() - .addService(service) - .build() - .start() - val channel = InProcessChannelBuilder.forName(address).directExecutor().build() - try { - val client = HelloClient(channel) - assertEquals("Hello, World!", client.sayHello("World")) - val error = - assertThrows(StatusException::class.java) { runBlocking { client.sayHello("") } } - assertEquals(Status.Code.INVALID_ARGUMENT, error.status.code) - } finally { - channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS) - server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS) - } - } -} diff --git a/docs/cloud-hello-plan.md b/docs/cloud-hello-plan.md new file mode 100644 index 0000000..93fb9db --- /dev/null +++ b/docs/cloud-hello-plan.md @@ -0,0 +1,45 @@ +# Android Cloud Hello integration + +## Scope and collected gaps + +Base: Mobile `65e9547a209839b6cb6c8983eb8b6ffd22423554`. The deployed Cloud health endpoint reports Native AOT at `6554400c04817491fe68d5e6319434034c5dc356`. Contracts CI published Maven release `1.0.0-ci.36.1`, including `contracts-connect-client`. + +The review covered the application entry point, shared UI/preview, transport, Android manifest, R8 rules, unit/device checks, dependency verification and candidate publication before implementation: + +1. Android renders a local protobuf round trip. Its unused native gRPC adapter cannot call the current Worker gRPC-Web ingress. +2. Contracts is pinned to `1.0.0-ci.25.1`; neither the Connect client nor its Android HTTP/serialization adapters are present. +3. The synchronous UI has no request progress, recoverable errors, cancellation or pending-request recreation policy. Its offline label would misrepresent a connected screen. +4. Existing tests verify local messages/in-process native gRPC and offline rendering. Neither Android TLS/networking nor the R8-minified client's actual Cloud call is a release gate. + +The installed JDK 21, SDK 37/Build-Tools 37 and API 36 emulator are sufficient. Keep Java/Kotlin bytecode at 21. No new Cloudflare credential, domain, product API, account flow or signing identity is needed. + +## Unified implementation + +1. Consume the exact published Connect client `1.0.0-ci.36.1`, Connect-Kotlin OkHttp and Google Java-lite adapters `0.9.0`. Remove the obsolete native gRPC adapter/dependencies from this app. Update strict locks/checksums and dependency automation; do not modify Contracts or Cloud. +2. Android owns a reusable transport per Activity. Use binary `NetworkProtocol.GRPC_WEB`, normal platform TLS validation and the fixed `https://arcforges.com/api` base. The SDK adds the method path once. Use a five-second RPC deadline, bounded HTTP calls, no automatic retry/redirect, no embedded credential and no offline success fallback. Close the transport when its owner is destroyed. +3. Make the shared screen accept a suspending greeting operation. Only an explicit button press sends a request. Disable duplicate submission while pending, preserve name and completed result through recreation, and cancel pending work when the composition is disposed. A recreated screen allows a fresh manual request; it never resumes/replays a canceled request. Preserve text verbatim and validate the Hello name's 1..256 UTF-16-unit limit. Show useful bounded error messages and allow manual retry. Keep the JVM Hot Reload preview explicitly local and offline. +4. Replace native in-process unit tests with gRPC-Web success/status/deadline/cancellation checks against a local HTTP fixture using the published messages and client. Add device UI checks for progress, failure/retry and disposal, alongside real online success/recreation and input validation. A separate Android integration test checks actual HTTPS path, media type, deadline, success/Unicode and gRPC application errors against Cloud, recording the observed deployment identity. +5. Extend the device smoke to press the real button in the R8 release APK and require the actual Cloud greeting. CI must pass this and Android instrumentation before protected signing/publication of the same candidate. Preserve protocol/device evidence. Update usage and validation documentation without claiming physical-device, account, Play Store or complete product acceptance. + +## Execution and closure + +Implement dependencies/transport, then shared UI/lifecycle, tests/device tooling, CI and documentation. Validate strict dependency restoration, formatting, unit tests, lint, JVM 21 output, debug and minified APK/AAB builds. Run the installed API 36 emulator locally, then both OS builds and the mandatory CI device gate. Review the final diff and open a PR from the isolated worktree. Fix concrete validation failures only within this scope; do not expand into product API design. + +No merge or main release is claimed by a PR check. Current Cloud calls are real anonymous Hello requests. Local HTTP fixtures, emulator operation, physical devices and publication remain distinct evidence categories. + +## Local validation record (2026-09-16) + +- JDK 21, SDK/Build-Tools 37 and an isolated read-only API 36 x86_64 emulator were used. Application/shared class files target JVM 21. Strict restoration with an empty Gradle cache passed; both preview-platform lock graphs were refreshed without weakening checksum verification. +- Formatting, repository checks, actionlint, strict release lint, four transport unit tests, both shared JVM/Android host test suites and three existing release guard tests passed. Debug APK/test APK, R8 release APK and release AAB built successfully. +- All five Android instrumentation tests passed. Eight real SDK RPCs verified binary media types, the single `/api` prefix, deadline metadata, Unicode/whitespace/boundary input, and gRPC statuses `INVALID_ARGUMENT`, `RESOURCE_EXHAUSTED` and `DEADLINE_EXCEEDED`. Observed Cloud Worker/Native AOT revision: `6554400c04817491fe68d5e6319434034c5dc356`; Contracts: `1.0.0-ci.36.1`. +- The actual Android screen called Cloud and retained the response across recreation. Controlled UI failures covered progress, duplicate submission, manual retry and cancellation without replay. A separately installed, minified release APK made one button-triggered live call and displayed `Hello, World!`. +- Validation exposed two concrete runtime defects, both fixed and retested: TLS socket cleanup on Activity destruction needed a background thread, and R8 removed the generated `getDefaultInstance()` used reflectively by the SDK's deserializer. StrictMode, code shrinking and real online release checks remain enabled. +- Local logs, protocol JSON, UI hierarchy and screenshot are under ignored `artifacts/`; CI preserves corresponding evidence as workflow artifacts. A disposable local key signed the test release APK. This is emulator evidence, not a physical-device test, new main publication, Play submission or full product acceptance. The PR must still pass both OS builds and its CI device gate before merge. +- The first PR CI run passed both OS builds, security scans and all eight direct Android SDK calls, but its UI success wait timed out without recording the screen's error state. The UI test now scrolls the button into view and reports the actual semantics tree on failure; CI saves logcat/protocol evidence before enforcing success. All five tests also passed locally at CI's 320x640/dpi160 dimensions with animations disabled. The first timeout was not reproduced locally; this does not establish a network or server defect, and neither application deadlines nor retry policy was relaxed. + +## References + +- [Contracts Kotlin client consumption](https://github.com/ArcForges/Contracts/blob/main/docs/consuming.md) +- [Connect-Kotlin client setup](https://connectrpc.com/docs/kotlin/getting-started/) +- [Connect-Kotlin errors](https://connectrpc.com/docs/kotlin/errors/) +- [Compose effects and coroutine lifecycle](https://developer.android.com/develop/ui/compose/side-effects) diff --git a/docs/development.md b/docs/development.md index b0fdf45..6155be7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,11 +8,11 @@ No Node, npm, CMake, NDK or neighboring source checkout is required for this boo ## Layout and commands -- `app/src/main`: Android entry point and published protobuf/native gRPC adapter. +- `app/src/main`: Android entry point and published Connect-Kotlin gRPC-Web adapter. - `shared/src/commonMain`: the same UI and greeting behavior used by Android and the development preview. - `shared/src/desktopMain`: a JVM window hosting that UI. No desktop installer or native distribution is published. -- `app/src/test`: public Contracts serialization and real in-process gRPC success/status tests. -- `app/src/androidTest`: device UI interaction and Activity recreation tests. +- `app/src/test`: published client interoperability with a loopback HTTP fixture, application/HTTP errors, deadlines and coroutine cancellation. No live service is required for unit tests. +- `app/src/androidTest`: UI progress/error/retry/disposal checks, real Cloud greeting/recreation and direct Android HTTPS protocol verification. - `eng`: repository, bytecode, candidate, signing and device checks. ```sh @@ -36,10 +36,22 @@ Run `./gradlew :shared:hotRunDesktop` from Windows x64 or Linux x64. Edit a comp Stop the preview before cleaning or rebuilding its outputs from another Gradle process. Use a separate worktree if a preview and an independent clean validation must run simultaneously. -The JVM preview uses the shared greeting function. Android wraps that same behavior in a local protobuf round trip to exercise the published Contracts message types under R8. The app does not claim to contact a backend. `HelloClient` demonstrates the published coroutine RPC stub with a deadline; the caller must own a TLS channel and its lifecycle before using it in a future connected screen. +The JVM preview uses the shared local greeting function and is explicitly labeled offline. Android supplies `CloudHelloClient.greet`, a suspending operation backed by the published Connect client. Launch and recomposition send no requests. One button press makes one RPC; the input/button are disabled while pending. A disposed composition cancels its coroutine; Activity destruction closes the owned HTTP transport. Name and completed result are saveable, pending work is not resumed after recreation, and failures allow manual retry. The Hello limit is 1..256 UTF-16 code units and text is preserved verbatim. Android devices do not run this JVM preview. Use [Android Studio Live Edit](https://developer.android.com/develop/ui/compose/tooling/iterative-development) for supported Android edits, or reinstall the debug APK. See [Compose Hot Reload](https://kotlinlang.org/docs/multiplatform/compose-hot-reload.html) for changes requiring a restart and runtime requirements. +## Cloud protocol and device verification + +`CloudHelloClient` uses `contracts-connect-client:1.0.0-ci.36.1`, the matching lite messages, and Connect-Kotlin OkHttp/Google Java-lite adapters 0.9.0. It explicitly selects `NetworkProtocol.GRPC_WEB` against `https://arcforges.com/api`; the SDK appends `/arcforges.hello.v1.HelloService/SayHello` once. Public native gRPC and Connect's default protocol are not used at this Worker ingress. INTERNET permission and the platform TLS trust store are sufficient. Cleartext traffic remains disabled, and no credential, custom trust manager or certificate bypass is added. + +The RPC deadline is five seconds and the HTTP call limit is ten seconds. Redirects and connection-failure retries are disabled. gRPC status errors produce bounded user-facing messages; there is no local-success fallback. In-flight disposal does not replay the request. Transport cleanup runs off the Activity's main thread because closing a TLS connection can perform network I/O. A future authenticated API must define its own session rules; this anonymous Hello is not an authentication template. + +Keep R8 enabled. The Google Java-lite strategy obtains response prototypes through `Internal.getDefaultInstance(Class)`, which reflects the generated static `getDefaultInstance()` method. The app's ProGuard rules preserve that method and protobuf-lite message fields; keeping fields alone builds successfully but breaks response decoding in a minified APK. The release device gate covers this runtime behavior. + +`connectedDebugAndroidTest` includes real anonymous requests to the currently deployed Cloud service and requires Internet access. `CloudHelloIntegrationTest` waits for health separately, records the observed Native AOT/Worker revision, then sends eight SDK calls without retry: four successful names, empty and oversized names, expired timeout and malformed timeout. It checks `/api`, binary Content-Type, SDK timeout metadata and decoded gRPC statuses. A separate UI test presses the actual Android button and verifies the result across Activity recreation. Fault/UI-state fixtures remain separate evidence from those live calls. + +CI downloads the previously built candidate, verifies its hashes, runs instrumentation, requires the `CLOUD_HELLO_VERIFIED` marker, and preserves `cloud-hello.json` under the Android evidence artifact. It then signs the same minified release APK with a disposable CI test key and runs `eng/device-smoke.py`: the script starts with an empty task, presses **Say hello** once and requires the actual server response. It stores the UI hierarchy, screenshot and `release-cloud.json`. Only after these gates can the protected job sign/publish the candidate with the persistent release identity. Live Cloud unavailability fails this gate; do not substitute a mock or auto-retry the application call to hide it. + ## Dependency maintenance Direct versions live in `gradle/libs.versions.toml`; Gradle and its distribution checksum live in `gradle/wrapper/gradle-wrapper.properties`. Do not use dynamic Maven versions or `mavenLocal()`. APK consumers do not generate `.proto` files themselves. @@ -65,4 +77,4 @@ CodeQL scans Java/Kotlin, Python and Actions. Kotlin 2.4.20 requires the same te ## Evidence boundaries -Unit tests establish shared behavior and generated API interoperability. Emulator tests establish rendered UI, state restoration and release APK startup on that image. Hot Reload requires a running preview and an observed edit/reload. Physical devices, production backend integration, Play submission and real user operation require separate evidence; none is implied by a green documentation or build check. +Unit tests establish shared behavior and generated API interoperability with local fixtures. Emulator tests establish Android UI/lifecycle, real HTTPS calls to the recorded deployment and the minified release client's operation on that image. Hot Reload requires a running preview and an observed edit/reload. Physical devices, accounts, full product behavior, Play submission and real user operation require separate evidence; none is implied by a green build. diff --git a/docs/releasing.md b/docs/releasing.md index 068b60d..fdecde8 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -4,7 +4,7 @@ `CI` runs on PRs, pushes to `main`, and manual validation requests. Both Windows and Linux build from the same commit with JDK/JVM 21. Unit tests, formatting, lint and bytecode verification must pass. The Linux build uploads unsigned release APK/AAB, debug/test APKs, the R8 mapping and `candidate.json` with SHA-256 hashes and source/version metadata. -The emulator job downloads that candidate, verifies its hashes, runs debug instrumentation and launches its minified release APK with a disposable test signature. Security scans run in parallel. The aggregate `Verify` check succeeds only when both OS builds, device checks and security checks succeed. +The emulator job downloads that candidate, verifies its hashes, runs debug instrumentation including real Cloud gRPC-Web calls, and invokes Hello from its minified release APK with a disposable test signature. Security scans run in parallel. The aggregate `Verify` check succeeds only when both OS builds, device checks and security checks succeed. No Cloudflare deployment credential is needed for the anonymous Hello gate; the currently deployed service must be available. Saved Android evidence records its observed revision, protocol outcomes and the release UI response. Only a `push` to `main` then enters `android-release`. It downloads and re-verifies the same candidate, aligns/signs its APK, signs its AAB and verifies the signatures and persistent certificate. It does not rebuild application code. GitHub Releases receives the signed APK/AAB, R8 mapping, `release.json` and `SHA256SUMS`. The development JVM preview is never released. PRs and manual validation runs never publish. diff --git a/eng/device-smoke.py b/eng/device-smoke.py index 7086508..f20a5f3 100644 --- a/eng/device-smoke.py +++ b/eng/device-smoke.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 -"""Install an APK and verify the rendered Hello World screen on an explicit Android device.""" +"""Install the minified APK and require one real, user-triggered Cloud greeting.""" import argparse import os +import json from pathlib import Path import subprocess +import re import time import xml.etree.ElementTree as ET @@ -26,19 +28,52 @@ def command(*parts): args.output.mkdir(parents=True, exist_ok=True) command("install", "-r", args.apk.resolve()) command("shell", "am", "force-stop", args.package) - command("shell", "am", "start", "-W", "-n", f"{args.package}/io.github.arcforges.mobile.MainActivity") + command("shell", "am", "start", "-W", "-f", "0x10008000", "-n", f"{args.package}/io.github.arcforges.mobile.MainActivity") + + def window(): + command("shell", "uiautomator", "dump", "/sdcard/arcforges-window.xml") + data = command("shell", "cat", "/sdcard/arcforges-window.xml") + (args.output / "window.xml").write_bytes(data) + return ET.fromstring(data) + deadline = time.monotonic() + 60 while time.monotonic() < deadline: - command("shell", "uiautomator", "dump", "/sdcard/arcforges-window.xml") - window = command("shell", "cat", "/sdcard/arcforges-window.xml") - root = ET.fromstring(window) - if any(node.get("text") == "Hello, World!" for node in root.iter("node")): - (args.output / "window.xml").write_bytes(window) - (args.output / "hello-world.png").write_bytes(command("exec-out", "screencap", "-p")) - print(f"Installed and rendered Hello World on {args.serial}: {args.package}") - return + root = window() + texts = {node.get("text") for node in root.iter("node")} + if "Ready to connect." in texts and "Cloud Hello 路 arcforges.com" in texts: + button = next(node for node in root.iter("node") if node.get("text") == "Say hello") + bounds = list(map(int, re.findall(r"\d+", button.get("bounds", "")))) + if len(bounds) != 4: + raise ValueError("Hello button has no usable device bounds.") + command("shell", "input", "tap", (bounds[0] + bounds[2]) // 2, (bounds[1] + bounds[3]) // 2) + break time.sleep(1) - raise SystemExit("The release APK did not render Hello, World! within 60 seconds.") + else: + raise SystemExit("The release APK did not present the Cloud action within 60 seconds.") + + # No automatic tap/RPC retry: a stale local greeting cannot satisfy this gate. + deadline = time.monotonic() + 20 + try: + while time.monotonic() < deadline: + root = window() + texts = {node.get("text", "") for node in root.iter("node")} + if "Hello, World!" in texts: + (args.output / "release-cloud.json").write_text(json.dumps({ + "package": args.package, "serial": args.serial, + "endpoint": "https://arcforges.com/api", "button_presses": 1, + "response": "Hello, World!", "minified_apk": args.apk.name, + }, indent=2) + "\n", encoding="utf-8") + print(f"Minified APK called Cloud and rendered Hello, World! on {args.serial}: {args.package}") + return + failures = [text for text in texts if text.startswith(( + "Could not ", "Cloud did not ", "Cloud rejected ", "Cloud's request ", "The request was canceled", + ))] + if failures: + raise SystemExit("Release Cloud call failed: " + "; ".join(failures)) + time.sleep(1) + raise SystemExit("The release APK did not render the Cloud response within 20 seconds.") + finally: + (args.output / "hello-world.png").write_bytes(command("exec-out", "screencap", "-p")) if __name__ == "__main__": diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e7d0a9a..4772765 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,8 @@ compose = "1.12.0" material3 = "1.9.0" hot-reload = "1.2.0" activity = "1.13.0" -contracts = "1.0.0-ci.25.1" -grpc = "1.84.0" +contracts = "1.0.0-ci.36.1" +connect = "0.9.0" coroutines = "1.11.0" junit = "4.13.2" androidx-test-runner = "1.7.0" @@ -23,9 +23,9 @@ compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.r compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "compose" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" } -contracts-client = { module = "io.github.arcforges:contracts-client", version.ref = "contracts" } -grpc-okhttp = { module = "io.grpc:grpc-okhttp", version.ref = "grpc" } -grpc-inprocess = { module = "io.grpc:grpc-inprocess", version.ref = "grpc" } +contracts-client = { module = "io.github.arcforges:contracts-connect-client", version.ref = "contracts" } +connect-okhttp = { module = "com.connectrpc:connect-kotlin-okhttp", version.ref = "connect" } +connect-javalite = { module = "com.connectrpc:connect-kotlin-google-javalite-ext", version.ref = "connect" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } junit = { module = "junit:junit", version.ref = "junit" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } diff --git a/gradle/locks/shared-linux-x64.lockfile b/gradle/locks/shared-linux-x64.lockfile index 2bf47b3..ade31b5 100644 --- a/gradle/locks/shared-linux-x64.lockfile +++ b/gradle/locks/shared-linux-x64.lockfile @@ -384,14 +384,14 @@ org.jetbrains.kotlinx:atomicfu:0.28.0=allDevSourceSetsCompileDependenciesMetadat org.jetbrains.kotlinx:kotlinx-browser:0.5.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidHostTestResolvableDependenciesMetadata,androidMainResolvableDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopDevResolvableDependenciesMetadata,desktopMainResolvableDependenciesMetadata,desktopTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0=composeHotReloadMcp org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0=composeHotReloadMcp -org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopDevRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidLintTool,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopDevRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidLintTool,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=allDevSourceSetsCompileDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestResolvableDependenciesMetadata,androidHostTestRuntimeClasspath,androidLintTool,androidMainLintChecksClasspath,androidMainResolvableDependenciesMetadata,androidRuntimeClasspath,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestResolvableDependenciesMetadata,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidMainResolvableDependenciesMetadata,androidRuntimeClasspath,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.11.0=composeHotReloadDevTools org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.8.0=composeHotReloadDevTools diff --git a/gradle/locks/shared-windows-x64.lockfile b/gradle/locks/shared-windows-x64.lockfile index f0cae40..19b9b6a 100644 --- a/gradle/locks/shared-windows-x64.lockfile +++ b/gradle/locks/shared-windows-x64.lockfile @@ -384,14 +384,14 @@ org.jetbrains.kotlinx:atomicfu:0.28.0=allDevSourceSetsCompileDependenciesMetadat org.jetbrains.kotlinx:kotlinx-browser:0.5.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidHostTestResolvableDependenciesMetadata,androidMainResolvableDependenciesMetadata,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,desktopDevResolvableDependenciesMetadata,desktopMainResolvableDependenciesMetadata,desktopTestResolvableDependenciesMetadata,metadataCommonMainCompileClasspath,metadataCompileClasspath org.jetbrains.kotlinx:kotlinx-collections-immutable-jvm:0.4.0=composeHotReloadMcp org.jetbrains.kotlinx:kotlinx-collections-immutable:0.4.0=composeHotReloadMcp -org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopDevRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidLintTool,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopDevRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinKlibCommonizerClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestRuntimeClasspath,androidLintTool,androidMainLintChecksClasspath,androidRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopMainCompileClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=allDevSourceSetsCompileDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestResolvableDependenciesMetadata,androidHostTestRuntimeClasspath,androidLintTool,androidMainLintChecksClasspath,androidMainResolvableDependenciesMetadata,androidRuntimeClasspath,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopCompileClasspath,desktopDevCompileClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=allDevSourceSetsCompileDependenciesMetadata,allSourceSetsCompileDependenciesMetadata,allTestSourceSetsCompileDependenciesMetadata,androidCompileClasspath,androidHostTestCompileClasspath,androidHostTestLintChecksClasspath,androidHostTestResolvableDependenciesMetadata,androidHostTestRuntimeClasspath,androidMainLintChecksClasspath,androidMainResolvableDependenciesMetadata,androidRuntimeClasspath,commonMainResolvableDependenciesMetadata,commonTestResolvableDependenciesMetadata,composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,composeHotReloadDevTools,composeHotReloadMcp,desktopCompileClasspath,desktopDevCompileClasspath,desktopDevResolvableDependenciesMetadata,desktopDevRuntimeClasspath,desktopMainCompileClasspath,desktopMainResolvableDependenciesMetadata,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestCompileClasspath,desktopTestResolvableDependenciesMetadata,desktopTestRuntimeClasspath,metadataCommonMainCompileClasspath,metadataCompileClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.11.0=composeHotReloadDevTools org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.7.1=composeHotReloadDevDesktopDevRuntimeClasspath,composeHotReloadDevDesktopRuntimeClasspath,composeHotReloadDevDesktopTestRuntimeClasspath,desktopDevRuntimeClasspath,desktopMainRuntimeClasspath,desktopRuntimeClasspath,desktopTestRuntimeClasspath org.jetbrains.kotlinx:kotlinx-datetime-jvm:0.8.0=composeHotReloadDevTools diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 5e906a9..e88c510 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -63,6 +63,11 @@ + + + + + @@ -89,6 +94,14 @@ + + + + + + + + @@ -1285,6 +1298,14 @@ + + + + + + + + @@ -1804,6 +1825,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + @@ -1899,6 +1944,14 @@ + + + + + + + + @@ -2164,6 +2217,11 @@ + + + + + @@ -2190,6 +2248,11 @@ + + + + + @@ -2198,6 +2261,11 @@ + + + + + @@ -2216,6 +2284,11 @@ + + + + + @@ -2269,6 +2342,22 @@ + + + + + + + + + + + + + + + + @@ -2277,6 +2366,24 @@ + + + + + + + + + + + + + + + + + + @@ -2287,6 +2394,14 @@ + + + + + + + + @@ -2410,6 +2525,14 @@ + + + + + + + + @@ -2418,6 +2541,14 @@ + + + + + + + + @@ -2521,6 +2652,11 @@ + + + + + @@ -2542,11 +2678,24 @@ + + + + + + + + + + + + + @@ -2555,6 +2704,14 @@ + + + + + + + + @@ -2677,6 +2834,11 @@ + + + + + @@ -2685,6 +2847,14 @@ + + + + + + + + @@ -4530,6 +4700,14 @@ + + + + + + + + @@ -4699,6 +4877,14 @@ + + + + + + + + @@ -4715,6 +4901,14 @@ + + + + + + + + diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 5220829..212aa9b 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -62,6 +62,7 @@ kotlin { implementation(libs.compose.foundation) implementation(libs.compose.ui) implementation(libs.material3) + implementation(libs.coroutines.core) } commonTest.dependencies { implementation(kotlin("test")) } getByName("desktopMain") { diff --git a/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/ArcForgesApp.kt b/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/ArcForgesApp.kt index 5551a9f..66f2533 100644 --- a/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/ArcForgesApp.kt +++ b/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/ArcForgesApp.kt @@ -25,6 +25,8 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -33,11 +35,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch @Composable -fun ArcForgesApp(greet: (String) -> String = ::hello) { +fun ArcForgesApp( + greet: suspend (String) -> String = { hello(it) }, + initialMessage: String = "Hello, World!", + serviceLabel: String = "Local preview 路 Works offline", +) { var name by rememberSaveable { mutableStateOf("World") } - var greeting by rememberSaveable { mutableStateOf(greet("World")) } + var greeting by rememberSaveable { mutableStateOf(initialMessage) } + var error by rememberSaveable { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() val colors = lightColorScheme(primary = Color(0xFF305E46), background = Color(0xFFF5F6EF)) MaterialTheme(colorScheme = colors) { @@ -75,7 +86,7 @@ fun ArcForgesApp(greet: (String) -> String = ::hello) { verticalArrangement = Arrangement.spacedBy(20.dp), ) { Text( - greeting, + if (loading) "Connecting..." else greeting, Modifier.testTag("greeting"), style = MaterialTheme.typography.headlineSmall, ) @@ -85,18 +96,40 @@ fun ArcForgesApp(greet: (String) -> String = ::hello) { modifier = Modifier.fillMaxWidth().testTag("name"), label = { Text("Your name") }, singleLine = true, + enabled = !loading, + isError = name.length > 256, + supportingText = { Text("${name.length}/256") }, ) + error?.let { + Text(it, Modifier.testTag("error"), color = colors.error) + } Button( - onClick = { greeting = greet(name) }, - enabled = name.isNotEmpty(), + onClick = { + loading = true + error = null + scope.launch { + try { + greeting = greet(name) + } catch (canceled: CancellationException) { + throw canceled + } catch (failure: GreetingFailure) { + error = failure.message + } catch (_: Exception) { + error = "Could not complete the request. Please try again." + } finally { + loading = false + } + } + }, + enabled = !loading && name.length in 1..256, modifier = Modifier.fillMaxWidth().testTag("say-hello"), ) { - Text("Say hello") + Text(if (loading) "Connecting..." else "Say hello") } } } Text( - "Hello World 路 Works offline", + serviceLabel, style = MaterialTheme.typography.labelMedium, color = colors.onSurfaceVariant, ) diff --git a/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/Greeting.kt b/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/Greeting.kt index 5997101..edc3f92 100644 --- a/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/Greeting.kt +++ b/shared/src/commonMain/kotlin/io/github/arcforges/mobile/shared/Greeting.kt @@ -1,8 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 package io.github.arcforges.mobile.shared -/** The Contracts Hello World sample rejects an empty name and preserves all other text. */ +/** Local development preview only; the Android application supplies its Cloud operation. */ fun hello(name: String): String { - require(name.isNotEmpty()) { "Enter a name to say hello." } + require(name.length in 1..256) { "Use a name with 1 to 256 characters." } return "Hello, $name!" } + +/** A bounded, user-facing failure message supplied by the platform's transport adapter. */ +class GreetingFailure(message: String) : Exception(message)