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" }
-