From f9602a4a02d62365ff034f413cf43347393bb0f9 Mon Sep 17 00:00:00 2001 From: Rakha adi Date: Thu, 13 Nov 2025 19:55:52 +0700 Subject: [PATCH] feat: AlertSender, UI aksesibel, OkHttp, unit tests & README --- .idea/copilot.data.migration.agent.xml | 6 + .idea/copilot.data.migration.ask.xml | 6 + .idea/copilot.data.migration.ask2agent.xml | 6 + .idea/copilot.data.migration.edit.xml | 6 + README.md | 129 +++++++++++++--- app/build.gradle.kts | 4 + app/src/main/AndroidManifest.xml | 3 + .../ac/ubharajaya/panicbutton/MainActivity.kt | 17 +-- .../panicbutton/data/AlertSender.kt | 59 ++++++++ .../ubharajaya/panicbutton/ui/theme/Color.kt | 7 +- .../ubharajaya/panicbutton/ui/theme/Theme.kt | 140 ++++++++++++------ .../panicbutton/data/AlertSenderTest.kt | 29 ++++ .../panicbutton/data/SimpleMockWebServer.kt | 3 + .../panicbutton/data/TestMockServer.kt | 95 ++++++++++++ .../okhttp3/mockwebserver/MockWebServer.kt | 89 +++++++++++ gradle/libs.versions.toml | 5 +- 16 files changed, 517 insertions(+), 87 deletions(-) create mode 100644 .idea/copilot.data.migration.agent.xml create mode 100644 .idea/copilot.data.migration.ask.xml create mode 100644 .idea/copilot.data.migration.ask2agent.xml create mode 100644 .idea/copilot.data.migration.edit.xml create mode 100644 app/src/main/java/id/ac/ubharajaya/panicbutton/data/AlertSender.kt create mode 100644 app/src/test/java/id/ac/ubharajaya/panicbutton/data/AlertSenderTest.kt create mode 100644 app/src/test/java/id/ac/ubharajaya/panicbutton/data/SimpleMockWebServer.kt create mode 100644 app/src/test/java/id/ac/ubharajaya/panicbutton/data/TestMockServer.kt create mode 100644 app/src/test/java/okhttp3/mockwebserver/MockWebServer.kt diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..4ea72a9 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..7ef04e2 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..8648f94 --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 743d652..82aecdb 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,109 @@ -# Panic Button Mobile Application -## Repository Praktikum +# Panic Button -Aplikasi ini adalah untuk mengirimkan sinyal bencana dan permintaan bantuan melalui perangkat mobile. +A simple Android app (Compose / Material3) that sends a panic alert to an ntfy-compatible endpoint. This repository includes a small accessible UI, a separated network sender (`AlertSender`), and unit tests for the sender logic. -Repository ini digunakan untuk praktikum perkuliahan pemrograman mobile. +--- -## Dosen -- Arif Dwiyanto: arif.dwiyanto@ubharajaya.ac.id +## Ringkasan fitur +- Large, high-contrast circular panic button with accessibility labels +- Separated network logic in `AlertSender` using OkHttp (asynchronous) +- `processResponseCode` helper for easy unit testing +- Unit tests covering the response code logic +- `INTERNET` permission declared in `AndroidManifest.xml` -## Mahasiswa -- 1. Dendi Yogia Pratama (202310715051) -- 2. Nuryuda Maulana (202310715038) -- 3. Haga Dalpinto Ginting (202310715176) -- 4. Rafi Fattan Fitriardi (202310715002) -- 5. Fadlan Rivaldi (202310715280) -- 6. RAKHA ADI SAPUTRO (202310715083) -- 7. Arif Nurkhayan (202310715128) -- 8. Fazri Abdurrahman (202310715082) -- 9. Markco Van Nistelrooy Sitanggang (202310715181) -- 10. Muhammad Fadzel Hadean Rukrus (202310715220) -- 11. Yosep Gamaliel Mulia (202310715105) -- 12. Satrio Putra Wardani (202310715307) -- 13. Faris Naufal Priatna (202310715123) -- 14. Nabila suwandira (202310715066) -- 15. Indris Alpasela (202310715200) -- 16. Raihan Ariq Muzakki (202310715297) -- 17. Dirson Ali Wardana (202310715246) \ No newline at end of file +--- + +## Struktur penting +- `app/src/main/java/id/ac/ubharajaya/panicbutton/MainActivity.kt` — activity entry point +- `app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Theme.kt` — UI + theme + `MyApp` composable +- `app/src/main/java/id/ac/ubharajaya/panicbutton/data/AlertSender.kt` — OkHttp-based alert sender (async) +- `app/src/test/java/id/ac/ubharajaya/panicbutton/data/AlertSenderTest.kt` — unit tests +- Gradle version catalog: `gradle/libs.versions.toml` + +--- + +## Persyaratan +- JDK 11 +- Android Studio (direkomendasikan) atau Gradle wrapper +- Android SDK (compileSdk 36) sesuai konfigurasi module + +--- + +## Cara membangun dan menjalankan +Rekomendasi: buka proyek ini di Android Studio lalu klik Run (atau pilih device/emulator). + +Dari command line (Windows `cmd.exe`) di root proyek: + +```cmd +cd /d E:\androidProject\Panic-Button +# build debug APK +.\gradlew.bat :app:assembleDebug +# atau build + jalankan unit tests +.\gradlew.bat :app:testDebugUnitTest +``` + +Jika ingin membersihkan dan memaksa rebuild: + +```cmd +.\gradlew.bat clean :app:assembleDebug --rerun-tasks +``` + +--- + +## Menjalankan unit tests +Unit tests yang ada fokus pada helper `processResponseCode`. + +Dari root project: + +```cmd +# run all unit tests in app module +.\gradlew.bat :app:testDebugUnitTest + +# run specific test class (Gradle yang lebih baru): +.\gradlew.bat :app:testDebugUnitTest --tests "id.ac.ubharajaya.panicbutton.data.AlertSenderTest" + +# fallback: run single test by name +.\gradlew.bat :app:testDebugUnitTest -Dtest.single=AlertSenderTest +``` + +Report HTML dihasilkan di: + +``` +app/build/reports/tests/testDebugUnitTest/index.html +``` + +--- + +## Catatan implementasi penting +- `AlertSender`: + - Endpoint disimpan di `AlertSender.ENDPOINT` (mutable) sehingga mudah untuk testing atau mengalihkan ke server lain. + - Asynchronous HTTP dilakukan dengan OkHttp `OkHttpClient` dan `enqueue`. + - `processResponseCode(code, onResult)` mengekstrak logic sukses/gagal sehingga bisa diuji lokal tanpa jaringan. + +- UI & theme: + - `PanicButtonTheme` menggunakan Material3 color scheme yang menonjolkan `PanicRed` untuk tombol. + - `MyApp` menampilkan tombol bundar besar, indicator loading, dan pesan status. + +- Permissions: `AndroidManifest.xml` menyertakan ``. + +--- + +## Troubleshooting +- Jika Gradle tidak mengenali `--tests`, jalankan `.\gradlew.bat --version` untuk memeriksa versi Gradle wrapper; gunakan `:app:testDebugUnitTest` dan `-Dtest.single` jika perlu. +- Jika OkHttp class tidak ditemukan, lakukan Gradle sync di Android Studio atau jalankan `.\gradlew.bat --refresh-dependencies`. +- Untuk masalah jaringan saat mengirim alert, periksa: + - Endpoint `AlertSender.ENDPOINT` benar + - Perangkat/emulator punya koneksi internet + - Server ntfy menerima pesan tanpa autentikasi atau memerlukan header tambahan + +--- + +## Next steps / ide peningkatan +- Tambah integration tests yang menggunakan `MockWebServer` (butuh dependency dan Gradle sync) +- Tambah retry/backoff dan rate-limiting pada `AlertSender` +- Tambah telemetry/logging dan optional authentication for endpoint +- Improve accessibility further (haptics, TalkBack announcements) + +--- + Author: Rakha Adi Saaputro + NPM : 202310715083 \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb02648..5f7e670 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -50,7 +50,11 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.com.squareup.okhttp3.okhttp) testImplementation(libs.junit) + testImplementation(libs.com.squareup.okhttp3.mockwebserver) + // Explicit fallback in case version catalog isn't picked up by the IDE yet + testImplementation("com.squareup.okhttp3:mockwebserver:4.11.0") androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1421c65..2a30d75 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + MyApp(modifier = Modifier.padding(innerPadding)) } } } } } -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - @Preview(showBackground = true) @Composable fun GreetingPreview() { PanicButtonTheme { - Greeting("Android") + MyApp() } } \ No newline at end of file diff --git a/app/src/main/java/id/ac/ubharajaya/panicbutton/data/AlertSender.kt b/app/src/main/java/id/ac/ubharajaya/panicbutton/data/AlertSender.kt new file mode 100644 index 0000000..81c119d --- /dev/null +++ b/app/src/main/java/id/ac/ubharajaya/panicbutton/data/AlertSender.kt @@ -0,0 +1,59 @@ +package id.ac.ubharajaya.panicbutton.data + +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.IOException + +object AlertSender { + // Make endpoint mutable so tests can override it + var ENDPOINT: String = "https://ntfy.ubharajaya.ac.id/panic-button" + private val client = OkHttpClient() + + /** + * Send an alert message to the configured endpoint asynchronously using OkHttp. + * Calls onResult with Result.success(responseCode) on 2xx responses, or Result.failure(exception) + */ + fun sendAlert(message: String, onResult: (Result) -> Unit) { + val mediaType = "text/plain".toMediaType() + val requestBody = message.toRequestBody(mediaType) + + val request = Request.Builder() + .url(ENDPOINT) + .addHeader("Title", "Alert") + .addHeader("Priority", "urgent") + .addHeader("Tags", "alert warning,rotating_light") + .post(requestBody) + .build() + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + onResult(Result.failure(e)) + } + + override fun onResponse(call: Call, response: Response) { + try { + val code = response.code + // reuse helper to keep behavior consistent and testable + processResponseCode(code, onResult) + } finally { + response.close() + } + } + }) + } + + // Helper function that contains the logic for interpreting HTTP response codes. + // This is public so it can be unit-tested without network. + fun processResponseCode(code: Int, onResult: (Result) -> Unit) { + if (code in 200..299) { + onResult(Result.success(code)) + } else { + onResult(Result.failure(Exception("HTTP $code"))) + } + } +} diff --git a/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Color.kt b/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Color.kt index 8fc2706..2336ade 100644 --- a/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Color.kt +++ b/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Color.kt @@ -8,4 +8,9 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) + +// Panic-specific colors (high contrast) +val PanicRed = Color(0xFFB00020) +val PanicRedOn = Color(0xFFFFFFFF) +val PanicSuccess = Color(0xFF00A152) diff --git a/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Theme.kt b/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Theme.kt index 1614f52..8b690ac 100644 --- a/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Theme.kt +++ b/app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Theme.kt @@ -1,58 +1,104 @@ package id.ac.ubharajaya.panicbutton.ui.theme -import android.app.Activity -import android.os.Build -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext - -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import id.ac.ubharajaya.panicbutton.data.AlertSender @Composable -fun PanicButtonTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit -) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } +fun PanicButtonTheme(content: @Composable () -> Unit) { + // Create a small color scheme that emphasizes the panic red as the error color + val colorScheme = lightColorScheme( + primary = Purple40, + onPrimary = PanicRedOn, + secondary = PurpleGrey40, + onSecondary = PanicRedOn, + error = PanicRed, + onError = PanicRedOn, + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF000000), + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF000000) + ) MaterialTheme( colorScheme = colorScheme, typography = Typography, content = content ) +} + +@Composable +fun MyApp(modifier: Modifier = Modifier) { + var message by remember { mutableStateOf("Klik tombol untuk mengirim notifikasi") } + var isLoading by remember { mutableStateOf(false) } + var lastResultSuccess by remember { mutableStateOf(null) } + + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Title + Text( + text = "Panic Button", + style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 24.dp) + ) + + // Panic button: large, circular, high contrast + Button( + onClick = { + if (!isLoading) { + isLoading = true + lastResultSuccess = null + AlertSender.sendAlert("Notifikasi dari Panic Button") { result -> + isLoading = false + lastResultSuccess = result.isSuccess + message = result.fold({ "Notifikasi berhasil dikirim!" }, { "Gagal: ${it.message}" }) + } + } + }, + modifier = Modifier + .size(160.dp) + .clip(CircleShape) + .semantics { contentDescription = "Panic button - tekan untuk mengirim alert" }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + shape = CircleShape, + border = BorderStroke(2.dp, MaterialTheme.colorScheme.onError) + ) { + if (isLoading) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.onError, strokeWidth = 3.dp, modifier = Modifier.size(40.dp)) + } else { + Text(text = "ALERT", fontSize = 24.sp, fontWeight = FontWeight.Bold) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Status message (accessible) + Text(text = message, modifier = Modifier.semantics { contentDescription = "status-message" }) + + lastResultSuccess?.let { success -> + val statusText = if (success) "Sukses" else "Gagal" + val statusColor = if (success) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.error + Text(text = statusText, color = statusColor, modifier = Modifier.padding(top = 8.dp)) + } + } } \ No newline at end of file diff --git a/app/src/test/java/id/ac/ubharajaya/panicbutton/data/AlertSenderTest.kt b/app/src/test/java/id/ac/ubharajaya/panicbutton/data/AlertSenderTest.kt new file mode 100644 index 0000000..77bb35e --- /dev/null +++ b/app/src/test/java/id/ac/ubharajaya/panicbutton/data/AlertSenderTest.kt @@ -0,0 +1,29 @@ +package id.ac.ubharajaya.panicbutton.data + +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +class AlertSenderTest { + + @Test + fun `processResponseCode handles success code`() { + var called = false + AlertSender.processResponseCode(200) { result -> + called = true + result.fold({ code -> assertEquals(200, code) }, { err -> fail("Expected success") }) + } + assertTrue(called) + } + + @Test + fun `processResponseCode handles failure code`() { + var called = false + AlertSender.processResponseCode(500) { result -> + called = true + result.fold({ _ -> fail("Expected failure") }, { err -> assertTrue(err.message?.contains("HTTP") ?: false) }) + } + assertTrue(called) + } +} diff --git a/app/src/test/java/id/ac/ubharajaya/panicbutton/data/SimpleMockWebServer.kt b/app/src/test/java/id/ac/ubharajaya/panicbutton/data/SimpleMockWebServer.kt new file mode 100644 index 0000000..9cefa2c --- /dev/null +++ b/app/src/test/java/id/ac/ubharajaya/panicbutton/data/SimpleMockWebServer.kt @@ -0,0 +1,3 @@ +package id.ac.ubharajaya.panicbutton.data + +// File intentionally left blank because tests use OkHttp MockWebServer from the dependency diff --git a/app/src/test/java/id/ac/ubharajaya/panicbutton/data/TestMockServer.kt b/app/src/test/java/id/ac/ubharajaya/panicbutton/data/TestMockServer.kt new file mode 100644 index 0000000..8f8fb66 --- /dev/null +++ b/app/src/test/java/id/ac/ubharajaya/panicbutton/data/TestMockServer.kt @@ -0,0 +1,95 @@ +package id.ac.ubharajaya.panicbutton.data + +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URL +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.BlockingQueue +import java.util.concurrent.TimeUnit + +class TestMockResponse { + var code: Int = 200 + var body: String = "" + + fun setResponseCode(c: Int): TestMockResponse { code = c; return this } + fun setBody(b: String): TestMockResponse { body = b; return this } +} + +class TestMockServer { + private var serverSocket: ServerSocket? = null + private var port: Int = -1 + private val responses: BlockingQueue = ArrayBlockingQueue(100) + @Volatile private var running = false + private var thread: Thread? = null + + fun start() { + serverSocket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) + port = serverSocket!!.localPort + running = true + thread = Thread { acceptLoop() } + thread!!.start() + } + + fun enqueue(response: TestMockResponse) { + responses.put(response) + } + + fun url(path: String): URL { + val p = if (path.startsWith("/")) path else "/$path" + return URL("http://127.0.0.1:$port$p") + } + + fun shutdown() { + running = false + try { serverSocket?.close() } catch (_: Exception) {} + thread?.interrupt() + } + + private fun acceptLoop() { + try { + while (running) { + val socket = serverSocket!!.accept() + handleSocket(socket) + } + } catch (_: Exception) { + } + } + + private fun handleSocket(socket: Socket) { + Thread { + try { + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + var line: String? + do { line = reader.readLine() } while (line != null && line.isNotEmpty()) + + val resp = responses.poll(5, TimeUnit.SECONDS) ?: TestMockResponse().apply { code = 500; body = "No queued response" } + val bodyBytes = resp.body.toByteArray(Charsets.UTF_8) + val statusLine = "HTTP/1.1 ${resp.code} ${statusText(resp.code)}\r\n" + val headers = "Content-Type: text/plain; charset=utf-8\r\nContent-Length: ${bodyBytes.size}\r\nConnection: close\r\n\r\n" + socket.getOutputStream().use { out -> + out.write(statusLine.toByteArray()) + out.write(headers.toByteArray()) + out.write(bodyBytes) + out.flush() + } + } catch (_: Exception) { + } finally { + try { socket.close() } catch (_: Exception) {} + } + }.start() + } + + private fun statusText(code: Int): String = when (code) { + in 200..299 -> "OK" + 400 -> "Bad Request" + 401 -> "Unauthorized" + 403 -> "Forbidden" + 404 -> "Not Found" + 500 -> "Internal Server Error" + else -> "" + } +} + diff --git a/app/src/test/java/okhttp3/mockwebserver/MockWebServer.kt b/app/src/test/java/okhttp3/mockwebserver/MockWebServer.kt new file mode 100644 index 0000000..d618942 --- /dev/null +++ b/app/src/test/java/okhttp3/mockwebserver/MockWebServer.kt @@ -0,0 +1,89 @@ +package okhttp3.mockwebserver + +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URL +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.BlockingQueue +import java.util.concurrent.TimeUnit + +class MockResponse { + private var _code: Int = 200 + private var _body: String = "" + + fun setResponseCode(code: Int): MockResponse { _code = code; return this } + fun setBody(body: String): MockResponse { _body = body; return this } + + internal fun code(): Int = _code + internal fun body(): String = _body +} + +class MockWebServer { + private var serverSocket: ServerSocket? = null + private var port: Int = -1 + private val queue: BlockingQueue = ArrayBlockingQueue(100) + @Volatile private var running = false + private var thread: Thread? = null + + fun start() { + serverSocket = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1")) + port = serverSocket!!.localPort + running = true + thread = Thread { acceptLoop() } + thread!!.start() + } + + fun enqueue(response: MockResponse) { + queue.put(response) + } + + fun shutdown() { + running = false + try { serverSocket?.close() } catch (_: Exception) {} + thread?.interrupt() + } + + fun url(path: String): URL { + val p = if (path.startsWith("/")) path else "/$path" + return URL("http://127.0.0.1:$port$p") + } + + private fun acceptLoop() { + try { + while (running) { + val socket = serverSocket!!.accept() + handleSocket(socket) + } + } catch (_: Exception) { + } + } + + private fun handleSocket(socket: Socket) { + Thread { + try { + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + // read request headers + var line: String? + do { line = reader.readLine() } while (line != null && line.isNotEmpty()) + + val resp = queue.poll(5, TimeUnit.SECONDS) ?: MockResponse().setResponseCode(500).setBody("No queued response") + val bodyBytes = resp.body().toByteArray(Charsets.UTF_8) + val statusLine = "HTTP/1.1 ${resp.code()} OK\r\n" + val headers = "Content-Type: text/plain; charset=utf-8\r\nContent-Length: ${bodyBytes.size}\r\nConnection: close\r\n\r\n" + socket.getOutputStream().use { out -> + out.write(statusLine.toByteArray()) + out.write(headers.toByteArray()) + out.write(bodyBytes) + out.flush() + } + } catch (_: Exception) { + } finally { + try { socket.close() } catch (_: Exception) {} + } + }.start() + } +} + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f649454..534300d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,8 @@ espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.9.4" activityCompose = "1.11.0" composeBom = "2024.09.00" +okhttp = "4.11.0" +mockwebserver = "4.11.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,9 +26,10 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +com-squareup-okhttp3-okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +com-squareup-okhttp3-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "mockwebserver" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } -