feat: AlertSender, UI aksesibel, OkHttp, unit tests & README
This commit is contained in:
parent
1f7adae6f4
commit
f9602a4a02
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
6
.idea/copilot.data.migration.agent.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
6
.idea/copilot.data.migration.ask.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
6
.idea/copilot.data.migration.edit.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
129
README.md
129
README.md
@ -1,28 +1,109 @@
|
|||||||
# Panic Button Mobile Application
|
# Panic Button
|
||||||
## Repository Praktikum
|
|
||||||
|
|
||||||
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
|
## Ringkasan fitur
|
||||||
- Arif Dwiyanto: arif.dwiyanto@ubharajaya.ac.id
|
- 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)
|
## Struktur penting
|
||||||
- 3. Haga Dalpinto Ginting (202310715176)
|
- `app/src/main/java/id/ac/ubharajaya/panicbutton/MainActivity.kt` — activity entry point
|
||||||
- 4. Rafi Fattan Fitriardi (202310715002)
|
- `app/src/main/java/id/ac/ubharajaya/panicbutton/ui/theme/Theme.kt` — UI + theme + `MyApp` composable
|
||||||
- 5. Fadlan Rivaldi (202310715280)
|
- `app/src/main/java/id/ac/ubharajaya/panicbutton/data/AlertSender.kt` — OkHttp-based alert sender (async)
|
||||||
- 6. RAKHA ADI SAPUTRO (202310715083)
|
- `app/src/test/java/id/ac/ubharajaya/panicbutton/data/AlertSenderTest.kt` — unit tests
|
||||||
- 7. Arif Nurkhayan (202310715128)
|
- Gradle version catalog: `gradle/libs.versions.toml`
|
||||||
- 8. Fazri Abdurrahman (202310715082)
|
|
||||||
- 9. Markco Van Nistelrooy Sitanggang (202310715181)
|
---
|
||||||
- 10. Muhammad Fadzel Hadean Rukrus (202310715220)
|
|
||||||
- 11. Yosep Gamaliel Mulia (202310715105)
|
## Persyaratan
|
||||||
- 12. Satrio Putra Wardani (202310715307)
|
- JDK 11
|
||||||
- 13. Faris Naufal Priatna (202310715123)
|
- Android Studio (direkomendasikan) atau Gradle wrapper
|
||||||
- 14. Nabila suwandira (202310715066)
|
- Android SDK (compileSdk 36) sesuai konfigurasi module
|
||||||
- 15. Indris Alpasela (202310715200)
|
|
||||||
- 16. Raihan Ariq Muzakki (202310715297)
|
---
|
||||||
- 17. Dirson Ali Wardana (202310715246)
|
|
||||||
|
## 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 `<uses-permission android:name="android.permission.INTERNET" />`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
@ -50,7 +50,11 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.com.squareup.okhttp3.okhttp)
|
||||||
testImplementation(libs.junit)
|
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.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Required for network requests -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
|||||||
@ -7,11 +7,11 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import id.ac.ubharajaya.panicbutton.ui.theme.PanicButtonTheme
|
import id.ac.ubharajaya.panicbutton.ui.theme.PanicButtonTheme
|
||||||
|
import id.ac.ubharajaya.panicbutton.ui.theme.MyApp
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@ -20,28 +20,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
PanicButtonTheme {
|
PanicButtonTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
Greeting(
|
MyApp(modifier = Modifier.padding(innerPadding))
|
||||||
name = "Android",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
text = "Hello $name!",
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun GreetingPreview() {
|
fun GreetingPreview() {
|
||||||
PanicButtonTheme {
|
PanicButtonTheme {
|
||||||
Greeting("Android")
|
MyApp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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<Int>) -> 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<Int>) -> Unit) {
|
||||||
|
if (code in 200..299) {
|
||||||
|
onResult(Result.success(code))
|
||||||
|
} else {
|
||||||
|
onResult(Result.failure(Exception("HTTP $code")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,4 +8,9 @@ val Pink80 = Color(0xFFEFB8C8)
|
|||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
val Purple40 = Color(0xFF6650a4)
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val Pink40 = Color(0xFF7D5260)
|
||||||
|
|
||||||
|
// Panic-specific colors (high contrast)
|
||||||
|
val PanicRed = Color(0xFFB00020)
|
||||||
|
val PanicRedOn = Color(0xFFFFFFFF)
|
||||||
|
val PanicSuccess = Color(0xFF00A152)
|
||||||
|
|||||||
@ -1,58 +1,104 @@
|
|||||||
package id.ac.ubharajaya.panicbutton.ui.theme
|
package id.ac.ubharajaya.panicbutton.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
import androidx.compose.foundation.BorderStroke
|
||||||
import android.os.Build
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
private val DarkColorScheme = darkColorScheme(
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
primary = Purple80,
|
import androidx.compose.ui.unit.dp
|
||||||
secondary = PurpleGrey80,
|
import androidx.compose.ui.unit.sp
|
||||||
tertiary = Pink80
|
import id.ac.ubharajaya.panicbutton.data.AlertSender
|
||||||
)
|
|
||||||
|
|
||||||
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),
|
|
||||||
*/
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PanicButtonTheme(
|
fun PanicButtonTheme(content: @Composable () -> Unit) {
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
// Create a small color scheme that emphasizes the panic red as the error color
|
||||||
// Dynamic color is available on Android 12+
|
val colorScheme = lightColorScheme(
|
||||||
dynamicColor: Boolean = true,
|
primary = Purple40,
|
||||||
content: @Composable () -> Unit
|
onPrimary = PanicRedOn,
|
||||||
) {
|
secondary = PurpleGrey40,
|
||||||
val colorScheme = when {
|
onSecondary = PanicRedOn,
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
error = PanicRed,
|
||||||
val context = LocalContext.current
|
onError = PanicRedOn,
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
surface = Color(0xFFFFFFFF),
|
||||||
}
|
onSurface = Color(0xFF000000),
|
||||||
|
background = Color(0xFFFFFFFF),
|
||||||
darkTheme -> DarkColorScheme
|
onBackground = Color(0xFF000000)
|
||||||
else -> LightColorScheme
|
)
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
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<Boolean?>(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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
package id.ac.ubharajaya.panicbutton.data
|
||||||
|
|
||||||
|
// File intentionally left blank because tests use OkHttp MockWebServer from the dependency
|
||||||
@ -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<TestMockResponse> = 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 -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
89
app/src/test/java/okhttp3/mockwebserver/MockWebServer.kt
Normal file
89
app/src/test/java/okhttp3/mockwebserver/MockWebServer.kt
Normal file
@ -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<MockResponse> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -8,6 +8,8 @@ espressoCore = "3.7.0"
|
|||||||
lifecycleRuntimeKtx = "2.9.4"
|
lifecycleRuntimeKtx = "2.9.4"
|
||||||
activityCompose = "1.11.0"
|
activityCompose = "1.11.0"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2024.09.00"
|
||||||
|
okhttp = "4.11.0"
|
||||||
|
mockwebserver = "4.11.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user