12 KiB
🔧 Implementation Notes & Configuration Guide
Lokasi Default Campus (UBH)
Koordinat default yang digunakan dalam aplikasi:
- Latitude: -6.2030
- Longitude: 107.0045
- Radius: 100 meter
Cara Mengubah Lokasi
Edit file MainActivity.kt, di fungsi isWithinAbsensiRadius():
fun isWithinAbsensiRadius(
studentLat: Double,
studentLon: Double,
campusLat: Double = -6.2030, // ← UBAH INI
campusLon: Double = 107.0045, // ← UBAH INI
radiusMeters: Float = 100f // ← UBAH INI (dalam meter)
): Boolean {
val distance = calculateDistance(studentLat, studentLon, campusLat, campusLon)
return distance <= radiusMeters
}
Webhook Configuration
Webhook URL
Testing Endpoint:
https://n8n.lab.ubharajaya.ac.id/webhook-test/23c6993d-1792-48fb-ad1c-ffc78a3e6254
Production Endpoint:
https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254
Cara Mengganti Webhook URL
Edit file MainActivity.kt, di fungsi kirimKeN8n():
fun kirimKeN8n(
context: ComponentActivity,
db: DatabaseHelper,
npm: String,
nama: String,
latitude: Double,
longitude: Double,
foto: Bitmap
) {
thread {
try {
// ...validation code...
val url = URL("https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254")
// ↑ UBAH URL DI SINI ↑
val conn = url.openConnection() as HttpURLConnection
// ...rest of code...
}
}
}
Privacy & Coordinate Obfuscation
Current Implementation
Saat ini, aplikasi mengirimkan koordinat asli ke server. Jika ingin menambahkan obfuscation:
// Di dalam kirimKeN8n(), sebelum membuat JSON:
val (obfuscatedLat, obfuscatedLon) = obfuscateCoordinates(latitude, longitude)
val json = JSONObject().apply {
put("npm", npm)
put("nama", nama)
put("latitude", obfuscatedLat) // ← Gunakan obfuscated
put("longitude", obfuscatedLon) // ← Gunakan obfuscated
put("timestamp", System.currentTimeMillis())
put("foto_base64", bitmapToBase64(foto))
put("validation_status", status)
put("is_within_radius", isValidLocation)
}
Customize Offset
Edit nilai offsetDegrees dalam fungsi obfuscateCoordinates():
fun obfuscateCoordinates(
latitude: Double,
longitude: Double,
offsetDegrees: Double = 0.002 // ← UBAH INI
): Pair<Double, Double> {
val randomLat = latitude + (Math.random() - 0.5) * offsetDegrees
val randomLon = longitude + (Math.random() - 0.5) * offsetDegrees
return Pair(randomLat, randomLon)
}
Offset Reference:
- 0.001 derajat ≈ 111 meter
- 0.002 derajat ≈ 222 meter
- 0.01 derajat ≈ 1.1 km
Database Configuration
Initial Database Setup
Database otomatis dibuat pada first launch aplikasi di method onCreate() di MainActivity:
class MainActivity : ComponentActivity() {
private lateinit var db: DatabaseHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
db = DatabaseHelper(this) // ← Database initialized here
enableEdgeToEdge()
// ...
}
}
Database Location
SQLite database tersimpan di:
/data/data/id.ac.ubharajaya.sistemakademik/databases/Akademik.db
Debugging Database
Menggunakan Android Studio:
- Device Explorer → data → data → id.ac.ubharajaya.sistemakademik → databases
- Download
Akademik.db - Buka dengan SQLite browser
Atau menggunakan adb:
adb shell
sqlite3 /data/data/id.ac.ubharajaya.sistemakademik/databases/Akademik.db
SELECT * FROM attendance;
Photo Handling
Current Implementation
Foto diambil menggunakan intent:
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraLauncher.launch(intent)
Foto disimpan sebagai Bitmap di memory dan dikonversi ke Base64 untuk dikirim.
Limitations
- Foto tidak disimpan ke storage permanent
- Memory terbatas untuk foto besar
- Foto hilang jika app closed
Enhancement: Save to File
Untuk menyimpan foto ke device storage:
// Tambahkan di build.gradle.kts
implementation("androidx.compose.material:material-icons-extended:1.6.0")
// Update permission di AndroidManifest.xml
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
// Update function untuk save foto
fun saveFotoToFile(bitmap: Bitmap, npm: String): String {
val filename = "absensi_${npm}_${System.currentTimeMillis()}.jpg"
val file = File(context.getExternalFilesDir(null), filename)
val fos = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos)
fos.close()
return file.absolutePath
}
Permission Handling
Required Permissions
Semua permissions sudah dideklarasikan di AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.CAMERA"/>
Runtime Permission Requests
Aplikasi menggunakan ActivityResultContracts untuk request permissions di runtime:
val locationPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
// Location permission granted
}
}
val cameraPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
// Camera permission granted
}
}
Validation Logic
Location Validation Flow
User clicks "Kirim Absensi"
↓
Check: latitude != null && longitude != null && foto != null
├─ NO → Show toast "Lokasi atau foto belum lengkap"
└─ YES → kirimKeN8n()
↓
Validate location using isWithinAbsensiRadius()
├─ Within radius → status = "success"
└─ Outside radius → status = "invalid_location"
↓
Save to database
Send to webhook
Show feedback toast
Custom Validation
Untuk menambah validasi lainnya, edit kirimKeN8n():
fun kirimKeN8n(
context: ComponentActivity,
db: DatabaseHelper,
npm: String,
nama: String,
latitude: Double,
longitude: Double,
foto: Bitmap
) {
thread {
try {
// Custom validation 1: Check time
val calendar = Calendar.getInstance()
val hour = calendar.get(Calendar.HOUR_OF_DAY)
if (hour < 7 || hour > 17) {
// Absensi hanya boleh di jam 7-17
context.runOnUiThread {
Toast.makeText(context, "Absensi hanya boleh jam 7-17", Toast.LENGTH_SHORT).show()
}
return@thread
}
// Custom validation 2: Location validation
val isValidLocation = isWithinAbsensiRadius(latitude, longitude)
val status = if (isValidLocation) "success" else "invalid_location"
// Save & send...
}
}
}
Error Handling
Network Error Handling
try {
val url = URL(webhookUrl)
val conn = url.openConnection() as HttpURLConnection
conn.requestMethod = "POST"
conn.setRequestProperty("Content-Type", "application/json")
conn.doOutput = true
// Send request...
val responseCode = conn.responseCode
context.runOnUiThread {
val message = when {
!isValidLocation -> "Absensi ditolak: Lokasi tidak sesuai"
responseCode == 200 -> "Absensi diterima server"
else -> "Absensi ditolak server"
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
context.runOnUiThread {
Toast.makeText(context, "Gagal kirim ke server: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
Improved Error Handling
Tambahkan specific exception handling:
catch (e: java.net.UnknownHostException) {
// Network not available
Toast.makeText(context, "Tidak ada koneksi internet", Toast.LENGTH_SHORT).show()
} catch (e: java.net.SocketTimeoutException) {
// Server timeout
Toast.makeText(context, "Server tidak merespons (timeout)", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
// Other errors
Toast.makeText(context, "Error: ${e.localizedMessage}", Toast.LENGTH_SHORT).show()
}
Logging & Debugging
Add Logging
import android.util.Log
class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "EAS_DEBUG"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "MainActivity created")
// ...
}
}
fun kirimKeN8n(...) {
thread {
try {
Log.d(TAG, "Starting attendance submission")
Log.d(TAG, "Location: $latitude, $longitude")
Log.d(TAG, "Validation status: $status")
// ... rest of code ...
Log.d(TAG, "Response code: $responseCode")
}
}
}
View Logs
adb logcat | grep EAS_DEBUG
Performance Optimization
Threading
Semua network operations sudah di thread terpisah:
thread {
// Long-running operations tidak akan freeze UI
val url = URL(webhookUrl)
val conn = url.openConnection() as HttpURLConnection
// ... network code ...
}
Bitmap Compression
Foto sudah dikompres sebelum convert ke Base64:
fun bitmapToBase64(bitmap: Bitmap): String {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, outputStream) // 80% quality
return Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
}
Build & Release
Build Configuration
android {
compileSdk = 36
minSdk = 28
targetSdk = 36
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
Build APK
./gradlew clean assembleDebug // Debug APK
./gradlew clean assembleRelease // Release APK
APK akan tersimpan di: app/build/outputs/apk/
Useful Commands
Clear App Data
adb shell pm clear id.ac.ubharajaya.sistemakademik
View Database
adb pull /data/data/id.ac.ubharajaya.sistemakademik/databases/Akademik.db
Install APK
adb install -r app/build/outputs/apk/debug/app-debug.apk
View Logs
adb logcat id.ac.ubharajaya.sistemakademik:V *:S
Testing Scenarios
Test Case 1: Happy Path
- Register user
- Login
- Grant permissions
- Di lokasi yang valid
- Ambil foto
- Kirim absensi
- ✅ Absensi diterima
- Cek di riwayat
Test Case 2: Invalid Location
- Login
- Di lokasi yang TIDAK valid (>100m dari campus)
- Ambil foto
- Kirim absensi
- ✅ Absensi ditolak dengan pesan lokasi
- Cek status di database = "invalid_location"
Test Case 3: Permission Denied
- Login
- Deny location permission
- ✅ Show dialog atau request ulang
Test Case 4: Network Error
- Turn off WiFi & Mobile data
- Coba kirim absensi
- ✅ Show error message
- Data tetap tersimpan di database lokal
Last Updated: 14 January 2026